phlex-hanami 0.2.0.pre.alpha.1 → 0.2.0.pre.alpha.2
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 +32 -2
- data/lib/phlex/hanami/errors/mailer_view_error.rb +30 -0
- data/lib/phlex/hanami/errors/relative_path_error.rb +26 -0
- data/lib/phlex/hanami/extensions/mailer.rb +102 -0
- data/lib/phlex/hanami/extensions/slice.rb +20 -2
- data/lib/phlex/hanami/mailer/layout.rb +44 -0
- data/lib/phlex/hanami/mailer/renderable.rb +129 -0
- data/lib/phlex/hanami/mailer/text.rb +124 -0
- data/lib/phlex/hanami/mailer/view.rb +38 -0
- data/lib/phlex/hanami/mailer.rb +47 -0
- data/lib/phlex/hanami/renderable.rb +8 -2
- data/lib/phlex/hanami/{slice_configured_view.rb → slice_configured.rb} +6 -6
- data/lib/phlex/hanami.rb +4 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb2ded5cb201e8636daeba76f21fc25b297899eba517ce428d6fcc41450460f9
|
|
4
|
+
data.tar.gz: f8935646735d5d5e52faf6c48b5713dae2f4169c02e8162fc4356c32e5cf09bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 396360b1808f053b39c29047103c6f860c46ac6bf436890a483848c63639c1e382fc0ad34eefb61f58335a4d158783349670c2b0ee30a421473737c5111e2abf
|
|
7
|
+
data.tar.gz: 84fbc91b5c45c6ece40da01070cd64bd9b2702cbc19c10c1ba8eeb3d27cc7aab57e3e60434305b18021dc980cc0a1fff983a051d5cce33b09b200fd87667db52
|
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [v0.2.0-alpha.
|
|
10
|
+
## [v0.2.0-alpha.2] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Phlex views for [hanami-mailer]. A mailer renders the Phlex class whose container key matches its own, so
|
|
15
|
+
`MyApp::Mailers::Welcome` renders `MyApp::Views::Mailers::Welcome` with no configuration.
|
|
16
|
+
- `Phlex::Hanami::Mailer::View` and `Phlex::Hanami::Mailer::Layout`, and `Phlex::Hanami::Mailer::Renderable` for an
|
|
17
|
+
existing `Phlex::HTML` base class.
|
|
18
|
+
- A plain text alternative part, converted from the rendered HTML by `Phlex::Hanami::Mailer::Text`. Override
|
|
19
|
+
`text_body` on the view to write it by hand.
|
|
20
|
+
- A mail layout convention: `Views::Mailers::Layout` in a slice wraps that slice's mail views.
|
|
21
|
+
- Usage documentation under [docs/usage](docs/usage/README.md), covering views, layouts, the view context,
|
|
22
|
+
helpers, mailers and how the gem hooks into Hanami.
|
|
23
|
+
- `Phlex::Hanami::MailerViewError`, raised when a mailer is paired with a Phlex view that is not a mail view,
|
|
24
|
+
rather than sending a message whose plain text part is markup.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `url` in a view returns a String. Hanami's routes helper returns a `URI`, which Phlex rejects as an attribute
|
|
29
|
+
value, so `a(href: url(:posts))` raised.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `path` in a mail view raises `Phlex::Hanami::RelativePathError` instead of writing a relative link an email client
|
|
34
|
+
cannot follow.
|
|
35
|
+
|
|
36
|
+
[hanami-mailer]: https://github.com/hanami/mailer
|
|
37
|
+
|
|
38
|
+
## [v0.2.0-alpha.1] - 2026-09-04
|
|
11
39
|
|
|
12
40
|
Initial alpha release by the new maintainer [@aaronmallen](https://github.com/aaronmallen).
|
|
13
41
|
|
|
@@ -15,5 +43,7 @@ Initial alpha release by the new maintainer [@aaronmallen](https://github.com/aa
|
|
|
15
43
|
|
|
16
44
|
Initial release, by the previous maintainer [@stephannv](https://github.com/stephannv).
|
|
17
45
|
|
|
18
|
-
[Unreleased]: https://github.com/aaronmallen/phlex-hanami/compare/0.
|
|
46
|
+
[Unreleased]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0-alpha.2...HEAD
|
|
47
|
+
[v0.2.0-alpha.2]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0-alpha.1...0.2.0-alpha.2
|
|
48
|
+
[v0.2.0-alpha.1]: https://github.com/aaronmallen/phlex-hanami/releases/tag/0.2.0-alpha.1
|
|
19
49
|
[v0.1.0]: https://github.com/stephannv/phlex-hanami/releases/tag/v0.1.0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Hanami
|
|
5
|
+
# Raised when a mailer's paired Phlex view cannot render a mail message.
|
|
6
|
+
#
|
|
7
|
+
# A mailer asks its view for two parts, and an ordinary view answers both with markup — which
|
|
8
|
+
# would ship an email whose plain text part is HTML. Better to say so than to send it.
|
|
9
|
+
#
|
|
10
|
+
# @api public
|
|
11
|
+
# @since 0.2.0
|
|
12
|
+
class MailerViewError < Error
|
|
13
|
+
# @api private
|
|
14
|
+
# @since 0.2.0
|
|
15
|
+
def initialize(mailer_class, view_class)
|
|
16
|
+
super(<<~MESSAGE)
|
|
17
|
+
#{mailer_class} is paired with #{view_class}, which is not a mail view.
|
|
18
|
+
|
|
19
|
+
A mail view renders both parts of the message. Subclass the mail base class:
|
|
20
|
+
|
|
21
|
+
class #{view_class} < Phlex::Hanami::Mailer::View
|
|
22
|
+
|
|
23
|
+
or include the module into the base class you already have:
|
|
24
|
+
|
|
25
|
+
include Phlex::Hanami::Mailer::Renderable
|
|
26
|
+
MESSAGE
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Hanami
|
|
5
|
+
# Raised when a mail view asks for a relative path.
|
|
6
|
+
#
|
|
7
|
+
# An email is read outside the app, so a relative path in one is a dead link. Mail views raise
|
|
8
|
+
# rather than render one.
|
|
9
|
+
#
|
|
10
|
+
# @api public
|
|
11
|
+
# @since 0.2.0
|
|
12
|
+
class RelativePathError < Error
|
|
13
|
+
# @api private
|
|
14
|
+
# @since 0.2.0
|
|
15
|
+
def initialize(view_class)
|
|
16
|
+
super(<<~MESSAGE)
|
|
17
|
+
#{view_class} asked for a relative path, and an email needs a full URL.
|
|
18
|
+
|
|
19
|
+
Use `url` instead:
|
|
20
|
+
|
|
21
|
+
a(href: url(:post, id: @post.id)) { @post.title }
|
|
22
|
+
MESSAGE
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Hanami
|
|
5
|
+
module Extensions
|
|
6
|
+
# Pairs a Hanami mailer with a Phlex view.
|
|
7
|
+
#
|
|
8
|
+
# Prepended onto `Hanami::Mailer`, so it sits in front of hanami-mailer's own view handling
|
|
9
|
+
# and answers first. A mailer's view is any object taking `call(format:, **input)`, which a
|
|
10
|
+
# {Phlex::Hanami::Mailer::View} already is, so pairing is the whole of the integration:
|
|
11
|
+
# nothing here teaches hanami-mailer what Phlex is.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
# @since 0.2.0
|
|
15
|
+
module Mailer
|
|
16
|
+
# Prepends this onto `Hanami::Mailer`.
|
|
17
|
+
#
|
|
18
|
+
# Called from `phlex/hanami.rb` when hanami-mailer is bundled. Zeitwerk keeps the mail
|
|
19
|
+
# classes unloaded until something names one, but a prepend needs the constant, so the
|
|
20
|
+
# check belongs at the call site.
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
# @since 0.2.0
|
|
24
|
+
def self.install
|
|
25
|
+
::Hanami::Mailer.prepend(self)
|
|
26
|
+
::Hanami::Mailer.singleton_class.prepend(ClassMethods)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @api private
|
|
30
|
+
# @since 0.2.0
|
|
31
|
+
module ClassMethods
|
|
32
|
+
# @api private
|
|
33
|
+
# @since 0.2.0
|
|
34
|
+
def configure_for_slice(slice)
|
|
35
|
+
super
|
|
36
|
+
extend SliceConfigured.new(slice)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The mailer's paired Phlex view, or nil when it has none.
|
|
40
|
+
#
|
|
41
|
+
# The key is derived the way hanami-mailer derives a template name: underscore the class
|
|
42
|
+
# name and drop the slice's own segment, so `MyApp::Mailers::Welcome` looks up
|
|
43
|
+
# `views.mailers.welcome`. Resolved through the container on every call rather than
|
|
44
|
+
# memoized, so code reloading is not defeated.
|
|
45
|
+
#
|
|
46
|
+
# @return [Class, nil]
|
|
47
|
+
#
|
|
48
|
+
# @raise [MailerViewError] when the paired view is a Phlex class that cannot render mail
|
|
49
|
+
#
|
|
50
|
+
# @api private
|
|
51
|
+
# @since 0.2.0
|
|
52
|
+
def phlex_view
|
|
53
|
+
mailer_slice = slice
|
|
54
|
+
return nil unless mailer_slice && name
|
|
55
|
+
|
|
56
|
+
key = "views.#{view_name(mailer_slice)}"
|
|
57
|
+
return nil unless mailer_slice.key?(key)
|
|
58
|
+
|
|
59
|
+
view = mailer_slice[key]
|
|
60
|
+
return nil unless view.is_a?(::Class) && view < ::Phlex::SGML
|
|
61
|
+
raise MailerViewError.new(self, view) unless view < ::Phlex::Hanami::Mailer::Renderable
|
|
62
|
+
|
|
63
|
+
view
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The slice this mailer belongs to, or nil when it is defined outside a slice namespace.
|
|
67
|
+
#
|
|
68
|
+
# @return [Hanami::Slice, nil]
|
|
69
|
+
#
|
|
70
|
+
# @api private
|
|
71
|
+
# @since 0.2.0
|
|
72
|
+
def slice
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def view_name(mailer_slice)
|
|
79
|
+
mailer_slice.inflector
|
|
80
|
+
.underscore(name)
|
|
81
|
+
.sub(%r{^#{mailer_slice.slice_name.path}/}, "")
|
|
82
|
+
.tr("/", ".")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The view this mailer renders.
|
|
87
|
+
#
|
|
88
|
+
# A view passed to the constructor wins, then the paired Phlex view, then whatever
|
|
89
|
+
# hanami-mailer would have used on its own — so an app can render some of its mail with
|
|
90
|
+
# Phlex and the rest with templates.
|
|
91
|
+
#
|
|
92
|
+
# @return [Object, nil]
|
|
93
|
+
#
|
|
94
|
+
# @api private
|
|
95
|
+
# @since 0.2.0
|
|
96
|
+
def view
|
|
97
|
+
@view || self.class.phlex_view || super
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -53,7 +53,9 @@ module Phlex
|
|
|
53
53
|
# Defines `Views::Context` for the slice when nothing else has.
|
|
54
54
|
#
|
|
55
55
|
# Does nothing when hanami-view is bundled, because Hanami defines its own richer context
|
|
56
|
-
# there, and
|
|
56
|
+
# there, and returns the existing one when the user has defined it themselves.
|
|
57
|
+
#
|
|
58
|
+
# @return [Class, nil] the slice's context class
|
|
57
59
|
#
|
|
58
60
|
# @api private
|
|
59
61
|
# @since 0.2.0
|
|
@@ -61,7 +63,7 @@ module Phlex
|
|
|
61
63
|
return if ::Hanami.bundled?("hanami-view")
|
|
62
64
|
|
|
63
65
|
namespace = views_namespace(slice)
|
|
64
|
-
return if namespace.const_defined?(:Context, false)
|
|
66
|
+
return namespace.const_get(:Context, false) if namespace.const_defined?(:Context, false)
|
|
65
67
|
|
|
66
68
|
# The class is anonymous at this point, so the slice cannot be inferred from its name
|
|
67
69
|
# the way `Hanami::SliceConfigurable` would; configure it explicitly instead.
|
|
@@ -83,6 +85,22 @@ module Phlex
|
|
|
83
85
|
component_dirs.instance = COMPONENT_INSTANCE
|
|
84
86
|
end
|
|
85
87
|
|
|
88
|
+
# The class an action's view context is built from, for anything rendering outside a
|
|
89
|
+
# request that needs the same one.
|
|
90
|
+
#
|
|
91
|
+
# Reads the third-party slot `Hanami::Extensions::Action::SliceConfiguredAction` reads,
|
|
92
|
+
# so a mail view and a request view are given the same class.
|
|
93
|
+
#
|
|
94
|
+
# @return [Class]
|
|
95
|
+
#
|
|
96
|
+
# @api private
|
|
97
|
+
# @since 0.2.0
|
|
98
|
+
def view_context_class(slice)
|
|
99
|
+
return ::Hanami::Extensions::View::Context.context_class(slice) if ::Hanami.bundled?("hanami-view")
|
|
100
|
+
|
|
101
|
+
define_view_context(slice)
|
|
102
|
+
end
|
|
103
|
+
|
|
86
104
|
private
|
|
87
105
|
|
|
88
106
|
def views_namespace(slice)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Hanami
|
|
5
|
+
module Mailer
|
|
6
|
+
# The base class for mail layouts.
|
|
7
|
+
#
|
|
8
|
+
# Name one `Views::Mailers::Layout` in a slice's namespace and every mail view in that slice
|
|
9
|
+
# renders inside it. The web layout is never used for mail; set a different one with `layout`,
|
|
10
|
+
# or opt out with `layout nil`.
|
|
11
|
+
#
|
|
12
|
+
# The layout is part of both message parts: the text part is converted from the HTML the
|
|
13
|
+
# layout produced, so a footer written once appears in both.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# # app/views/mailers/layout.rb
|
|
17
|
+
# module MyApp
|
|
18
|
+
# module Views
|
|
19
|
+
# module Mailers
|
|
20
|
+
# class Layout < Phlex::Hanami::Mailer::Layout
|
|
21
|
+
# def view_template
|
|
22
|
+
# html do
|
|
23
|
+
# body do
|
|
24
|
+
# yield
|
|
25
|
+
# p { "Sent by MyApp" }
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# @api public
|
|
35
|
+
# @since 0.2.0
|
|
36
|
+
class Layout < Phlex::HTML
|
|
37
|
+
include Renderable
|
|
38
|
+
|
|
39
|
+
# A layout is never itself wrapped in a layout.
|
|
40
|
+
layout nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Hanami
|
|
5
|
+
module Mailer
|
|
6
|
+
# Makes a Phlex class renderable by a Hanami mailer.
|
|
7
|
+
#
|
|
8
|
+
# Include this into an existing `Phlex::SGML` subclass, or subclass {View}, which includes it
|
|
9
|
+
# already. It builds on {Phlex::Hanami::Renderable} and adds the three things mail needs: a
|
|
10
|
+
# context with no request behind it, a second format for the plain text part, and a layout
|
|
11
|
+
# convention of its own.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# module MyApp
|
|
15
|
+
# class MailerView < Phlex::HTML
|
|
16
|
+
# include Phlex::Hanami::Mailer::Renderable
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
21
|
+
# @since 0.2.0
|
|
22
|
+
module Renderable
|
|
23
|
+
# @api private
|
|
24
|
+
# @since 0.2.0
|
|
25
|
+
def self.included(view_class)
|
|
26
|
+
view_class.include(::Phlex::Hanami::Renderable)
|
|
27
|
+
|
|
28
|
+
# Order matters: a module included later sits higher in the lookup chain, so the mail
|
|
29
|
+
# methods have to go in after `Renderable`, not alongside them in this module.
|
|
30
|
+
view_class.include(MailMethods)
|
|
31
|
+
view_class.extend(ClassMethods)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @api public
|
|
35
|
+
# @since 0.2.0
|
|
36
|
+
module ClassMethods
|
|
37
|
+
# Renders one part of a mail message.
|
|
38
|
+
#
|
|
39
|
+
# This is what `Hanami::Mailer#render_view` calls, once per part, with the format naming
|
|
40
|
+
# the part it wants. Hanami's mailer has no view context to hand over — there is no
|
|
41
|
+
# request to build one from — so a mail view builds the slice's own context itself.
|
|
42
|
+
#
|
|
43
|
+
# @param context [Object, nil] the Hanami view context, built from the slice when omitted
|
|
44
|
+
# @param format [Symbol] `:html` or `:text`
|
|
45
|
+
# @param input [Hash] the mailer's exposures
|
|
46
|
+
#
|
|
47
|
+
# @return [String] the rendered part
|
|
48
|
+
#
|
|
49
|
+
# @api public
|
|
50
|
+
# @since 0.2.0
|
|
51
|
+
def call(context: nil, format: :html, **input)
|
|
52
|
+
html = super(context: context || mail_context, **input)
|
|
53
|
+
return html unless format.to_s == "text"
|
|
54
|
+
|
|
55
|
+
new(**accepted_input(input)).text_body(html)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# A slice's conventional mail layout: `Views::Mailers::Layout`, if it defines one.
|
|
61
|
+
#
|
|
62
|
+
# The web layout is the wrong one for an email — it carries the stylesheets, scripts and
|
|
63
|
+
# page chrome no mail client wants — so mail looks for its own, and renders without a
|
|
64
|
+
# layout when the slice has none.
|
|
65
|
+
def default_layout
|
|
66
|
+
return nil unless slice
|
|
67
|
+
return nil unless slice.namespace.const_defined?(:Views, false)
|
|
68
|
+
|
|
69
|
+
views = slice.namespace.const_get(:Views, false)
|
|
70
|
+
return nil unless views.const_defined?(:Mailers, false)
|
|
71
|
+
|
|
72
|
+
mailers = views.const_get(:Mailers, false)
|
|
73
|
+
mailers.const_get(:Layout, false) if mailers.const_defined?(:Layout, false)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The slice's view context, built without a request.
|
|
77
|
+
#
|
|
78
|
+
# The same class an action would be given, so a view reads the same in both places. What
|
|
79
|
+
# a request would have filled in — `request`, `session`, `flash`, `csrf_token` — is
|
|
80
|
+
# missing, and the context says so when a view reaches for it.
|
|
81
|
+
def mail_context
|
|
82
|
+
return nil unless slice
|
|
83
|
+
|
|
84
|
+
Extensions::Slice.view_context_class(slice).new
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# What a mail view does differently.
|
|
89
|
+
#
|
|
90
|
+
# Included after `Phlex::Hanami::Renderable` so that it wins: a module included later sits
|
|
91
|
+
# higher in the lookup chain.
|
|
92
|
+
#
|
|
93
|
+
# @api private
|
|
94
|
+
# @since 0.2.0
|
|
95
|
+
module MailMethods
|
|
96
|
+
# Raises. An email is read outside the app, so a link in one needs a full URL.
|
|
97
|
+
#
|
|
98
|
+
# @raise [RelativePathError]
|
|
99
|
+
#
|
|
100
|
+
# @api public
|
|
101
|
+
# @since 0.2.0
|
|
102
|
+
def path(*, **)
|
|
103
|
+
raise RelativePathError, self.class
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The plain text part of the message.
|
|
107
|
+
#
|
|
108
|
+
# Called with the rendered HTML, layout and all, and converts it by default. Override it
|
|
109
|
+
# to write the text part by hand; the view's own state is there to read.
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# def text_body(_html)
|
|
113
|
+
# "Welcome, #{@user.name}. Get started: #{url(:root)}"
|
|
114
|
+
# end
|
|
115
|
+
#
|
|
116
|
+
# @param html [String] the rendered HTML part
|
|
117
|
+
#
|
|
118
|
+
# @return [String] the text part
|
|
119
|
+
#
|
|
120
|
+
# @api public
|
|
121
|
+
# @since 0.2.0
|
|
122
|
+
def text_body(html)
|
|
123
|
+
Text.call(html)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Hanami
|
|
5
|
+
module Mailer
|
|
6
|
+
# Turns the HTML part of a message into its plain text alternative.
|
|
7
|
+
#
|
|
8
|
+
# This is regex over markup Phlex has just produced, not a general HTML parser. It knows the
|
|
9
|
+
# handful of things that matter in an email — anchors, lists, line breaks, block elements and
|
|
10
|
+
# the entities Phlex escapes — and leaves everything else on the floor. Markup from somewhere
|
|
11
|
+
# else, pasted into a view with `raw`, may not come out well.
|
|
12
|
+
#
|
|
13
|
+
# {Renderable#text_body} calls this. Override that method to write the text part by hand.
|
|
14
|
+
#
|
|
15
|
+
# @api public
|
|
16
|
+
# @since 0.2.0
|
|
17
|
+
class Text
|
|
18
|
+
# Elements that end a line of text, and so become a blank line.
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
21
|
+
# @since 0.2.0
|
|
22
|
+
BLOCK_ELEMENTS = %w[
|
|
23
|
+
article blockquote div footer h1 h2 h3 h4 h5 h6 header hr li ol p section table td tr ul
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# @api private
|
|
27
|
+
# @since 0.2.0
|
|
28
|
+
BLOCK_PATTERN = %r{</?(?:#{BLOCK_ELEMENTS.join('|')})\b[^>]*>}i
|
|
29
|
+
|
|
30
|
+
# The entities Phlex escapes, and the characters they stand for.
|
|
31
|
+
#
|
|
32
|
+
# @api private
|
|
33
|
+
# @since 0.2.0
|
|
34
|
+
ENTITIES = {
|
|
35
|
+
"'" => "'",
|
|
36
|
+
"&" => "&",
|
|
37
|
+
"'" => "'",
|
|
38
|
+
">" => ">",
|
|
39
|
+
"<" => "<",
|
|
40
|
+
" " => " ",
|
|
41
|
+
""" => '"',
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# @api private
|
|
45
|
+
# @since 0.2.0
|
|
46
|
+
ENTITY_PATTERN = /&(?:amp|apos|gt|lt|nbsp|quot|#39);/
|
|
47
|
+
|
|
48
|
+
# Stand in for the angle brackets around a URL until the tags are gone, so that stripping
|
|
49
|
+
# tags does not eat the link the converter has just written.
|
|
50
|
+
#
|
|
51
|
+
# @api private
|
|
52
|
+
# @since 0.2.0
|
|
53
|
+
LINK_CLOSE = "\u0011"
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
# @since 0.2.0
|
|
57
|
+
LINK_OPEN = "\u0010"
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# Converts rendered HTML to plain text.
|
|
61
|
+
#
|
|
62
|
+
# @param html [String] the rendered HTML part
|
|
63
|
+
#
|
|
64
|
+
# @return [String] the text part
|
|
65
|
+
#
|
|
66
|
+
# @api public
|
|
67
|
+
# @since 0.2.0
|
|
68
|
+
def call(html)
|
|
69
|
+
text = body_of(html.to_s)
|
|
70
|
+
text = strip_hidden_elements(text)
|
|
71
|
+
text = expand_anchors(text)
|
|
72
|
+
text = expand_breaks(text)
|
|
73
|
+
text = expand_list_items(text)
|
|
74
|
+
text = expand_blocks(text)
|
|
75
|
+
text = strip_tags(text)
|
|
76
|
+
text = unescape(text)
|
|
77
|
+
restore_links(tidy(text))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# `Label <https://example.com/path>`, or one of the two when the other adds nothing.
|
|
83
|
+
def anchor_text(attributes, inner)
|
|
84
|
+
url = href_in(attributes)
|
|
85
|
+
label = strip_tags(inner).gsub(/\s+/, " ").strip
|
|
86
|
+
return label if url.empty?
|
|
87
|
+
return url if label.empty? || label == url
|
|
88
|
+
|
|
89
|
+
"#{label} #{LINK_OPEN}#{url}#{LINK_CLOSE}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def body_of(html) = html[%r{<body\b[^>]*>(.*?)</body>}mi, 1] || html
|
|
93
|
+
|
|
94
|
+
def expand_anchors(html)
|
|
95
|
+
html.gsub(%r{<a\b([^>]*)>(.*?)</a>}mi) { anchor_text(Regexp.last_match(1), Regexp.last_match(2)) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def expand_blocks(html) = html.gsub(BLOCK_PATTERN, "\n\n")
|
|
99
|
+
|
|
100
|
+
def expand_breaks(html) = html.gsub(%r{<br\b[^>]*/?>}i, "\n")
|
|
101
|
+
|
|
102
|
+
def expand_list_items(html) = html.gsub(%r{</li\s*>}i, "").gsub(/<li\b[^>]*>/i, "\n- ")
|
|
103
|
+
|
|
104
|
+
def href_in(attributes)
|
|
105
|
+
match = attributes.match(/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i)
|
|
106
|
+
return "" unless match
|
|
107
|
+
|
|
108
|
+
(match[1] || match[2] || match[3]).to_s.strip
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def restore_links(text) = text.gsub(LINK_OPEN, "<").gsub(LINK_CLOSE, ">")
|
|
112
|
+
|
|
113
|
+
def strip_hidden_elements(html) = html.gsub(%r{<(head|style|script)\b[^>]*>.*?</\1\s*>}mi, "")
|
|
114
|
+
|
|
115
|
+
def strip_tags(html) = html.gsub(/<!--.*?-->/m, "").gsub(/<[^>]*>/, "")
|
|
116
|
+
|
|
117
|
+
def tidy(text) = text.gsub(/[ \t]+/, " ").gsub(/[ \t]+$/, "").gsub(/\n{3,}/, "\n\n").strip
|
|
118
|
+
|
|
119
|
+
def unescape(text) = text.gsub(ENTITY_PATTERN) { ENTITIES.fetch(Regexp.last_match(0)) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Hanami
|
|
5
|
+
module Mailer
|
|
6
|
+
# The base class for Phlex views rendered by a Hanami mailer.
|
|
7
|
+
#
|
|
8
|
+
# Subclass this for a mail view, or for an app-level base mail view. If you already have your
|
|
9
|
+
# own `Phlex::HTML` base class, include {Renderable} into it instead — the two are equivalent.
|
|
10
|
+
#
|
|
11
|
+
# A mailer finds its view by name, the way an action does: `MyApp::Mailers::Welcome` renders
|
|
12
|
+
# `MyApp::Views::Mailers::Welcome`.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# # app/views/mailers/welcome.rb
|
|
16
|
+
# module MyApp
|
|
17
|
+
# module Views
|
|
18
|
+
# module Mailers
|
|
19
|
+
# class Welcome < Phlex::Hanami::Mailer::View
|
|
20
|
+
# def initialize(user:) = @user = user
|
|
21
|
+
#
|
|
22
|
+
# def view_template
|
|
23
|
+
# h1 { "Welcome, #{@user.name}" }
|
|
24
|
+
# p { a(href: url(:root)) { "Get started" } }
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# @api public
|
|
32
|
+
# @since 0.2.0
|
|
33
|
+
class View < Phlex::HTML
|
|
34
|
+
include Renderable
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Hanami
|
|
5
|
+
# Phlex views for hanami-mailer.
|
|
6
|
+
#
|
|
7
|
+
# A mailer renders the same view twice, once for each part of the message, so a mail view
|
|
8
|
+
# answers to a format: {Mailer::View} renders markup for the HTML part and converts that markup
|
|
9
|
+
# for the text part. Everything else a view can do it can do too, except the parts of the
|
|
10
|
+
# context that only exist during a request.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# # app/mailers/welcome.rb
|
|
14
|
+
# module MyApp
|
|
15
|
+
# module Mailers
|
|
16
|
+
# class Welcome < Hanami::Mailer
|
|
17
|
+
# from "hello@example.com"
|
|
18
|
+
# to { |user| user.email }
|
|
19
|
+
# subject "Welcome"
|
|
20
|
+
#
|
|
21
|
+
# expose :user
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # app/views/mailers/welcome.rb
|
|
27
|
+
# module MyApp
|
|
28
|
+
# module Views
|
|
29
|
+
# module Mailers
|
|
30
|
+
# class Welcome < Phlex::Hanami::Mailer::View
|
|
31
|
+
# def initialize(user:) = @user = user
|
|
32
|
+
#
|
|
33
|
+
# def view_template
|
|
34
|
+
# h1 { "Welcome, #{@user.name}" }
|
|
35
|
+
# p { a(href: url(:root)) { "Get started" } }
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# @api public
|
|
43
|
+
# @since 0.2.0
|
|
44
|
+
module Mailer
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -124,7 +124,7 @@ module Phlex
|
|
|
124
124
|
# @api private
|
|
125
125
|
# @since 0.2.0
|
|
126
126
|
def configure_for_slice(slice)
|
|
127
|
-
extend
|
|
127
|
+
extend SliceConfigured.new(slice)
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
# The layout configured on this class, or inherited from a superclass. {UNSET} when none has
|
|
@@ -354,12 +354,18 @@ module Phlex
|
|
|
354
354
|
|
|
355
355
|
# The full URL for a named route.
|
|
356
356
|
#
|
|
357
|
+
# Hanami's routes helper returns a `URI`; Phlex writes strings, and rejects anything else as
|
|
358
|
+
# an attribute value, so this hands back the string.
|
|
359
|
+
#
|
|
360
|
+
# @example
|
|
361
|
+
# a(href: url(:posts)) { "Posts" }
|
|
362
|
+
#
|
|
357
363
|
# @return [String]
|
|
358
364
|
#
|
|
359
365
|
# @api public
|
|
360
366
|
# @since 0.2.0
|
|
361
367
|
def url(...)
|
|
362
|
-
routes.url(...)
|
|
368
|
+
routes.url(...).to_s
|
|
363
369
|
end
|
|
364
370
|
|
|
365
371
|
private
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module Phlex
|
|
4
4
|
module Hanami
|
|
5
|
-
#
|
|
5
|
+
# Tells a class which slice it belongs to.
|
|
6
6
|
#
|
|
7
|
-
# `Hanami::SliceConfigurable` calls `configure_for_slice` once per slice as
|
|
8
|
-
# so a
|
|
9
|
-
# configured for that slice.
|
|
7
|
+
# `Hanami::SliceConfigurable` calls `configure_for_slice` once per slice as classes are defined,
|
|
8
|
+
# so a class in the app namespace is configured for the app and one in a slice namespace is
|
|
9
|
+
# configured for that slice. {Renderable} views and Hanami mailers both extend one of these.
|
|
10
10
|
#
|
|
11
11
|
# @api private
|
|
12
12
|
# @since 0.2.0
|
|
13
|
-
class
|
|
13
|
+
class SliceConfigured < Module
|
|
14
14
|
# The slice this module configures for.
|
|
15
15
|
#
|
|
16
16
|
# @return [Hanami::Slice]
|
|
@@ -28,7 +28,7 @@ module Phlex
|
|
|
28
28
|
|
|
29
29
|
# @api private
|
|
30
30
|
# @since 0.2.0
|
|
31
|
-
def extended(
|
|
31
|
+
def extended(_klass)
|
|
32
32
|
define_slice
|
|
33
33
|
end
|
|
34
34
|
|
data/lib/phlex/hanami.rb
CHANGED
|
@@ -42,3 +42,7 @@ end.setup
|
|
|
42
42
|
# Installs the integration. Referencing the constant is what autoloads it, so this cannot move into
|
|
43
43
|
# the file itself — nothing else would ever refer to it.
|
|
44
44
|
Hanami::Slice::ClassMethods.prepend(Phlex::Hanami::Extensions::Slice)
|
|
45
|
+
|
|
46
|
+
# Mail is a second entry point, and an optional one. Zeitwerk keeps the mail classes unloaded until
|
|
47
|
+
# something names one, but prepending onto `Hanami::Mailer` needs the constant to exist.
|
|
48
|
+
Phlex::Hanami::Extensions::Mailer.install if Hanami.bundled?("hanami-mailer")
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phlex-hanami
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.0.pre.alpha.
|
|
4
|
+
version: 0.2.0.pre.alpha.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Aaron Allen
|
|
@@ -66,13 +66,21 @@ files:
|
|
|
66
66
|
- lib/phlex/hanami.rb
|
|
67
67
|
- lib/phlex/hanami/context.rb
|
|
68
68
|
- lib/phlex/hanami/errors/error.rb
|
|
69
|
+
- lib/phlex/hanami/errors/mailer_view_error.rb
|
|
69
70
|
- lib/phlex/hanami/errors/missing_context_error.rb
|
|
71
|
+
- lib/phlex/hanami/errors/relative_path_error.rb
|
|
72
|
+
- lib/phlex/hanami/extensions/mailer.rb
|
|
70
73
|
- lib/phlex/hanami/extensions/slice.rb
|
|
71
74
|
- lib/phlex/hanami/helpers.rb
|
|
72
75
|
- lib/phlex/hanami/layout.rb
|
|
76
|
+
- lib/phlex/hanami/mailer.rb
|
|
77
|
+
- lib/phlex/hanami/mailer/layout.rb
|
|
78
|
+
- lib/phlex/hanami/mailer/renderable.rb
|
|
79
|
+
- lib/phlex/hanami/mailer/text.rb
|
|
80
|
+
- lib/phlex/hanami/mailer/view.rb
|
|
73
81
|
- lib/phlex/hanami/renderable.rb
|
|
82
|
+
- lib/phlex/hanami/slice_configured.rb
|
|
74
83
|
- lib/phlex/hanami/slice_configured_context.rb
|
|
75
|
-
- lib/phlex/hanami/slice_configured_view.rb
|
|
76
84
|
- lib/phlex/hanami/view.rb
|
|
77
85
|
- sig/phlex-hanami.rbs
|
|
78
86
|
homepage: https://github.com/aaronmallen/phlex-hanami
|