phlex-hanami 0.1.0 → 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 +46 -2
- data/{LICENSE.txt → LICENSE} +6 -6
- data/lib/phlex/hanami/context.rb +184 -0
- data/lib/phlex/hanami/errors/error.rb +12 -0
- data/lib/phlex/hanami/errors/mailer_view_error.rb +30 -0
- data/lib/phlex/hanami/errors/missing_context_error.rb +24 -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 +117 -0
- data/lib/phlex/hanami/helpers.rb +62 -3
- data/lib/phlex/hanami/layout.rb +39 -0
- 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 +379 -0
- data/lib/phlex/hanami/slice_configured.rb +50 -0
- data/lib/phlex/hanami/slice_configured_context.rb +70 -0
- data/lib/phlex/hanami/view.rb +39 -0
- data/lib/phlex/hanami.rb +40 -6
- data/lib/phlex-hanami.rb +3 -0
- data/sig/phlex-hanami.rbs +2 -0
- metadata +58 -29
- data/.rspec +0 -3
- data/.standard.yml +0 -5
- data/CODE_OF_CONDUCT.md +0 -132
- data/README.md +0 -43
- data/Rakefile +0 -10
- data/lib/phlex/hanami/assets_helper.rb +0 -23
- data/lib/phlex/hanami/context_helper.rb +0 -21
- data/lib/phlex/hanami/version.rb +0 -7
- data/sig/phlex/hanami.rbs +0 -6
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Hanami
|
|
5
|
+
# The base class for layouts.
|
|
6
|
+
#
|
|
7
|
+
# A layout is an ordinary Phlex class that yields the view's body. Name one `Views::Layout` in a
|
|
8
|
+
# slice's namespace and every view in that slice renders inside it; set a different one with
|
|
9
|
+
# `layout`, or opt out with `layout nil`.
|
|
10
|
+
#
|
|
11
|
+
# The view's body is rendered before the layout, so anything the view puts on the context — a
|
|
12
|
+
# `content_for(:page_title, ...)`, say — is already there when the layout renders its `head`.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# # app/views/layout.rb
|
|
16
|
+
# module MyApp
|
|
17
|
+
# module Views
|
|
18
|
+
# class Layout < Phlex::Hanami::Layout
|
|
19
|
+
# def view_template
|
|
20
|
+
# doctype
|
|
21
|
+
# html do
|
|
22
|
+
# head { title { hanami_context.content_for(:page_title) || "MyApp" } }
|
|
23
|
+
# body { yield }
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# @api public
|
|
31
|
+
# @since 0.2.0
|
|
32
|
+
class Layout < Phlex::HTML
|
|
33
|
+
include Renderable
|
|
34
|
+
|
|
35
|
+
# A layout is never itself wrapped in a layout.
|
|
36
|
+
layout nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -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
|