hanami-view 1.3.0.beta1 → 2.0.0.alpha2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/LICENSE +20 -0
- data/README.md +20 -862
- data/hanami-view.gemspec +26 -16
- data/lib/hanami-view.rb +3 -1
- data/lib/hanami/view.rb +208 -223
- data/lib/hanami/view/application_configuration.rb +77 -0
- data/lib/hanami/view/application_context.rb +35 -0
- data/lib/hanami/view/application_view.rb +89 -0
- data/lib/hanami/view/context.rb +97 -0
- data/lib/hanami/view/context_helpers/content_helpers.rb +26 -0
- data/lib/hanami/view/decorated_attributes.rb +82 -0
- data/lib/hanami/view/errors.rb +19 -56
- data/lib/hanami/view/exposure.rb +126 -0
- data/lib/hanami/view/exposures.rb +74 -0
- data/lib/hanami/view/part.rb +217 -0
- data/lib/hanami/view/part_builder.rb +140 -0
- data/lib/hanami/view/path.rb +68 -0
- data/lib/hanami/view/render_environment.rb +62 -0
- data/lib/hanami/view/render_environment_missing.rb +44 -0
- data/lib/hanami/view/rendered.rb +55 -0
- data/lib/hanami/view/renderer.rb +79 -0
- data/lib/hanami/view/scope.rb +189 -0
- data/lib/hanami/view/scope_builder.rb +98 -0
- data/lib/hanami/view/standalone_view.rb +396 -0
- data/lib/hanami/view/tilt.rb +78 -0
- data/lib/hanami/view/tilt/erb.rb +26 -0
- data/lib/hanami/view/tilt/erbse.rb +21 -0
- data/lib/hanami/view/tilt/haml.rb +26 -0
- data/lib/hanami/view/version.rb +5 -5
- metadata +113 -63
- data/LICENSE.md +0 -22
- data/lib/hanami/layout.rb +0 -172
- data/lib/hanami/presenter.rb +0 -98
- data/lib/hanami/view/configuration.rb +0 -504
- data/lib/hanami/view/dsl.rb +0 -347
- data/lib/hanami/view/escape.rb +0 -225
- data/lib/hanami/view/inheritable.rb +0 -54
- data/lib/hanami/view/rendering.rb +0 -294
- data/lib/hanami/view/rendering/layout_finder.rb +0 -128
- data/lib/hanami/view/rendering/layout_registry.rb +0 -69
- data/lib/hanami/view/rendering/layout_scope.rb +0 -274
- data/lib/hanami/view/rendering/null_layout.rb +0 -52
- data/lib/hanami/view/rendering/null_local.rb +0 -82
- data/lib/hanami/view/rendering/null_template.rb +0 -83
- data/lib/hanami/view/rendering/options.rb +0 -24
- data/lib/hanami/view/rendering/partial.rb +0 -31
- data/lib/hanami/view/rendering/partial_file.rb +0 -29
- data/lib/hanami/view/rendering/partial_finder.rb +0 -75
- data/lib/hanami/view/rendering/partial_templates_finder.rb +0 -73
- data/lib/hanami/view/rendering/registry.rb +0 -134
- data/lib/hanami/view/rendering/scope.rb +0 -108
- data/lib/hanami/view/rendering/subscope.rb +0 -56
- data/lib/hanami/view/rendering/template.rb +0 -69
- data/lib/hanami/view/rendering/template_finder.rb +0 -55
- data/lib/hanami/view/rendering/template_name.rb +0 -50
- data/lib/hanami/view/rendering/templates_finder.rb +0 -144
- data/lib/hanami/view/rendering/view_finder.rb +0 -37
- data/lib/hanami/view/template.rb +0 -57
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry/core/cache"
|
4
|
+
require "dry/core/equalizer"
|
5
|
+
require_relative "scope"
|
6
|
+
|
7
|
+
module Hanami
|
8
|
+
class View
|
9
|
+
# Builds scope objects via matching classes
|
10
|
+
#
|
11
|
+
# @api private
|
12
|
+
class ScopeBuilder
|
13
|
+
extend Dry::Core::Cache
|
14
|
+
include Dry::Equalizer(:namespace)
|
15
|
+
|
16
|
+
# The view's configured `scope_namespace`
|
17
|
+
#
|
18
|
+
# @api private
|
19
|
+
attr_reader :namespace
|
20
|
+
|
21
|
+
# @return [RenderEnvironment]
|
22
|
+
#
|
23
|
+
# @api private
|
24
|
+
attr_reader :render_env
|
25
|
+
|
26
|
+
# Returns a new instance of ScopeBuilder
|
27
|
+
#
|
28
|
+
# @api private
|
29
|
+
def initialize(namespace: nil, render_env: nil)
|
30
|
+
@namespace = namespace
|
31
|
+
@render_env = render_env
|
32
|
+
end
|
33
|
+
|
34
|
+
# @api private
|
35
|
+
def for_render_env(render_env)
|
36
|
+
return self if render_env == self.render_env
|
37
|
+
|
38
|
+
self.class.new(namespace: namespace, render_env: render_env)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Returns a new scope using a class matching the name
|
42
|
+
#
|
43
|
+
# @param name [Symbol, Class] scope name
|
44
|
+
# @param locals [Hash<Symbol, Object>] locals hash
|
45
|
+
#
|
46
|
+
# @return [Hanami::View::Scope]
|
47
|
+
#
|
48
|
+
# @api private
|
49
|
+
def call(name = nil, locals) # rubocop:disable Style/OptionalArguments
|
50
|
+
scope_class(name).new(
|
51
|
+
name: name,
|
52
|
+
locals: locals,
|
53
|
+
render_env: render_env
|
54
|
+
)
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
DEFAULT_SCOPE_CLASS = Scope
|
60
|
+
|
61
|
+
def scope_class(name = nil)
|
62
|
+
if name.nil?
|
63
|
+
DEFAULT_SCOPE_CLASS
|
64
|
+
elsif name.is_a?(Class)
|
65
|
+
name
|
66
|
+
else
|
67
|
+
fetch_or_store(namespace, name) do
|
68
|
+
resolve_scope_class(name: name)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def resolve_scope_class(name:)
|
74
|
+
name = inflector.camelize(name.to_s)
|
75
|
+
|
76
|
+
# Give autoloaders a chance to act
|
77
|
+
begin
|
78
|
+
klass = namespace.const_get(name)
|
79
|
+
rescue NameError # rubocop:disable Lint/HandleExceptions
|
80
|
+
end
|
81
|
+
|
82
|
+
if !klass && namespace.const_defined?(name, false)
|
83
|
+
klass = namespace.const_get(name)
|
84
|
+
end
|
85
|
+
|
86
|
+
if klass && klass < Scope
|
87
|
+
klass
|
88
|
+
else
|
89
|
+
DEFAULT_SCOPE_CLASS
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def inflector
|
94
|
+
render_env.inflector
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,396 @@
|
|
1
|
+
require_relative "scope"
|
2
|
+
|
3
|
+
module Hanami
|
4
|
+
class View
|
5
|
+
module StandaloneView
|
6
|
+
def self.included(klass)
|
7
|
+
klass.extend ClassMethods
|
8
|
+
klass.include InstanceMethods
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
# @api private
|
13
|
+
def inherited(klass)
|
14
|
+
super
|
15
|
+
|
16
|
+
exposures.each do |name, exposure|
|
17
|
+
klass.exposures.import(name, exposure)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# @!group Exposures
|
22
|
+
|
23
|
+
# @!macro [new] exposure_options
|
24
|
+
# @param options [Hash] the exposure's options
|
25
|
+
# @option options [Boolean] :layout expose this value to the layout (defaults to false)
|
26
|
+
# @option options [Boolean] :decorate decorate this value in a matching Part (defaults to
|
27
|
+
# true)
|
28
|
+
# @option options [Symbol, Class] :as an alternative name or class to use when finding a
|
29
|
+
# matching Part
|
30
|
+
|
31
|
+
# @overload expose(name, **options, &block)
|
32
|
+
# Define a value to be passed to the template. The return value of the
|
33
|
+
# block will be decorated by a matching Part and passed to the template.
|
34
|
+
#
|
35
|
+
# The block will be evaluated with the view instance as its `self`. The
|
36
|
+
# block's parameters will determine what it is given:
|
37
|
+
#
|
38
|
+
# - To receive other exposure values, provide positional parameters
|
39
|
+
# matching the exposure names. These exposures will already by decorated
|
40
|
+
# by their Parts.
|
41
|
+
# - To receive the view's input arguments (whatever is passed to
|
42
|
+
# `View#call`), provide matching keyword parameters. You can provide
|
43
|
+
# default values for these parameters to make the corresponding input
|
44
|
+
# keys optional
|
45
|
+
# - To receive the Context object, provide a `context:` keyword parameter
|
46
|
+
# - To receive the view's input arguments in their entirety, provide a
|
47
|
+
# keywords splat parameter (i.e. `**input`)
|
48
|
+
#
|
49
|
+
# @example Accessing input arguments
|
50
|
+
# expose :article do |slug:|
|
51
|
+
# article_repo.find_by_slug(slug)
|
52
|
+
# end
|
53
|
+
#
|
54
|
+
# @example Accessing other exposures
|
55
|
+
# expose :articles do
|
56
|
+
# article_repo.listing
|
57
|
+
# end
|
58
|
+
#
|
59
|
+
# expose :featured_articles do |articles|
|
60
|
+
# articles.select(&:featured?)
|
61
|
+
# end
|
62
|
+
#
|
63
|
+
# @param name [Symbol] name for the exposure
|
64
|
+
# @macro exposure_options
|
65
|
+
#
|
66
|
+
# @overload expose(name, **options)
|
67
|
+
# Define a value to be passed to the template, provided by an instance
|
68
|
+
# method matching the name. The method's return value will be decorated by
|
69
|
+
# a matching Part and passed to the template.
|
70
|
+
#
|
71
|
+
# The method's parameters will determine what it is given:
|
72
|
+
#
|
73
|
+
# - To receive other exposure values, provide positional parameters
|
74
|
+
# matching the exposure names. These exposures will already by decorated
|
75
|
+
# by their Parts.
|
76
|
+
# - To receive the view's input arguments (whatever is passed to
|
77
|
+
# `View#call`), provide matching keyword parameters. You can provide
|
78
|
+
# default values for these parameters to make the corresponding input
|
79
|
+
# keys optional
|
80
|
+
# - To receive the Context object, provide a `context:` keyword parameter
|
81
|
+
# - To receive the view's input arguments in their entirey, provide a
|
82
|
+
# keywords splat parameter (i.e. `**input`)
|
83
|
+
#
|
84
|
+
# @example Accessing input arguments
|
85
|
+
# expose :article
|
86
|
+
#
|
87
|
+
# def article(slug:)
|
88
|
+
# article_repo.find_by_slug(slug)
|
89
|
+
# end
|
90
|
+
#
|
91
|
+
# @example Accessing other exposures
|
92
|
+
# expose :articles
|
93
|
+
# expose :featured_articles
|
94
|
+
#
|
95
|
+
# def articles
|
96
|
+
# article_repo.listing
|
97
|
+
# end
|
98
|
+
#
|
99
|
+
# def featured_articles
|
100
|
+
# articles.select(&:featured?)
|
101
|
+
# end
|
102
|
+
#
|
103
|
+
# @param name [Symbol] name for the exposure
|
104
|
+
# @macro exposure_options
|
105
|
+
#
|
106
|
+
# @overload expose(name, **options)
|
107
|
+
# Define a single value to pass through from the input data (when there is
|
108
|
+
# no instance method matching the `name`). This value will be decorated by
|
109
|
+
# a matching Part and passed to the template.
|
110
|
+
#
|
111
|
+
# @param name [Symbol] name for the exposure
|
112
|
+
# @macro exposure_options
|
113
|
+
# @option options [Boolean] :default a default value to provide if there is no matching
|
114
|
+
# input data
|
115
|
+
#
|
116
|
+
# @overload expose(*names, **options)
|
117
|
+
# Define multiple values to pass through from the input data (when there
|
118
|
+
# is no instance methods matching their names). These values will be
|
119
|
+
# decorated by matching Parts and passed through to the template.
|
120
|
+
#
|
121
|
+
# The provided options will be applied to all the exposures.
|
122
|
+
#
|
123
|
+
# @param names [Symbol] names for the exposures
|
124
|
+
# @macro exposure_options
|
125
|
+
# @option options [Boolean] :default a default value to provide if there is no matching
|
126
|
+
# input data
|
127
|
+
#
|
128
|
+
# @see https://dry-rb.org/gems/dry-view/exposures/
|
129
|
+
#
|
130
|
+
# @api public
|
131
|
+
def expose(*names, **options, &block)
|
132
|
+
if names.length == 1
|
133
|
+
exposures.add(names.first, block, **options)
|
134
|
+
else
|
135
|
+
names.each do |name|
|
136
|
+
exposures.add(name, **options)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# @api public
|
142
|
+
def private_expose(*names, **options, &block)
|
143
|
+
expose(*names, **options, private: true, &block)
|
144
|
+
end
|
145
|
+
|
146
|
+
# Returns the defined exposures. These are unbound, since bound exposures
|
147
|
+
# are only created when initializing a View instance.
|
148
|
+
#
|
149
|
+
# @return [Exposures]
|
150
|
+
# @api private
|
151
|
+
def exposures
|
152
|
+
@exposures ||= Exposures.new
|
153
|
+
end
|
154
|
+
|
155
|
+
# @!endgroup
|
156
|
+
|
157
|
+
# @!group Scope
|
158
|
+
|
159
|
+
# Creates and assigns a scope for the current view.
|
160
|
+
#
|
161
|
+
# The newly created scope is useful to add custom logic that is specific
|
162
|
+
# to the view.
|
163
|
+
#
|
164
|
+
# The scope has access to locals, exposures, and inherited scope (if any)
|
165
|
+
#
|
166
|
+
# If the view already has an explicit scope the newly created scope will
|
167
|
+
# inherit from the explicit scope.
|
168
|
+
#
|
169
|
+
# There are two cases when this may happen:
|
170
|
+
# 1. The scope was explicitly assigned (e.g. `config.scope = MyScope`)
|
171
|
+
# 2. The scope has been inherited by the view superclass
|
172
|
+
#
|
173
|
+
# If the view doesn't have an already existing scope, the newly scope
|
174
|
+
# will inherit from `Hanami::View::Scope` by default.
|
175
|
+
#
|
176
|
+
# However, you can specify any base class for it. This is not
|
177
|
+
# recommended, unless you know what you're doing.
|
178
|
+
#
|
179
|
+
# @param scope [Hanami::View::Scope] the current scope (if any), or the
|
180
|
+
# default base class will be `Hanami::View::Scope`
|
181
|
+
# @param block [Proc] the scope logic definition
|
182
|
+
#
|
183
|
+
# @api public
|
184
|
+
#
|
185
|
+
# @example Basic usage
|
186
|
+
# class MyView < Hanami::View
|
187
|
+
# config.scope = MyScope
|
188
|
+
#
|
189
|
+
# scope do
|
190
|
+
# def greeting
|
191
|
+
# _locals[:message].upcase + "!"
|
192
|
+
# end
|
193
|
+
#
|
194
|
+
# def copyright(time)
|
195
|
+
# "Copy #{time.year}"
|
196
|
+
# end
|
197
|
+
# end
|
198
|
+
# end
|
199
|
+
#
|
200
|
+
# # my_view.html.erb
|
201
|
+
# # <%= greeting %>
|
202
|
+
# # <%= copyright(Time.now.utc) %>
|
203
|
+
#
|
204
|
+
# MyView.new.(message: "Hello") # => "HELLO!"
|
205
|
+
#
|
206
|
+
# @example Inherited scope
|
207
|
+
# class MyScope < Hanami::View::Scope
|
208
|
+
# private
|
209
|
+
#
|
210
|
+
# def shout(string)
|
211
|
+
# string.upcase + "!"
|
212
|
+
# end
|
213
|
+
# end
|
214
|
+
#
|
215
|
+
# class MyView < Hanami::View
|
216
|
+
# config.scope = MyScope
|
217
|
+
#
|
218
|
+
# scope do
|
219
|
+
# def greeting
|
220
|
+
# shout(_locals[:message])
|
221
|
+
# end
|
222
|
+
#
|
223
|
+
# def copyright(time)
|
224
|
+
# "Copy #{time.year}"
|
225
|
+
# end
|
226
|
+
# end
|
227
|
+
# end
|
228
|
+
#
|
229
|
+
# # my_view.html.erb
|
230
|
+
# # <%= greeting %>
|
231
|
+
# # <%= copyright(Time.now.utc) %>
|
232
|
+
#
|
233
|
+
# MyView.new.(message: "Hello") # => "HELLO!"
|
234
|
+
def scope(base: config.scope || Hanami::View::Scope, &block)
|
235
|
+
config.scope = Class.new(base, &block)
|
236
|
+
end
|
237
|
+
|
238
|
+
# @!endgroup
|
239
|
+
|
240
|
+
# @!group Render environment
|
241
|
+
|
242
|
+
# Returns a render environment for the view and the given options. This
|
243
|
+
# environment isn't chdir'ed into any particular directory.
|
244
|
+
#
|
245
|
+
# @param format [Symbol] template format to use (defaults to the `default_format` setting)
|
246
|
+
# @param context [Context] context object to use (defaults to the `default_context` setting)
|
247
|
+
#
|
248
|
+
# @see View.template_env render environment for the view's template
|
249
|
+
# @see View.layout_env render environment for the view's layout
|
250
|
+
#
|
251
|
+
# @return [RenderEnvironment]
|
252
|
+
# @api public
|
253
|
+
def render_env(format: config.default_format, context: config.default_context)
|
254
|
+
RenderEnvironment.prepare(renderer(format), config, context)
|
255
|
+
end
|
256
|
+
|
257
|
+
# @overload template_env(format: config.default_format, context: config.default_context)
|
258
|
+
# Returns a render environment for the view and the given options,
|
259
|
+
# chdir'ed into the view's template directory. This is the environment
|
260
|
+
# used when rendering the template, and is useful to to fetch
|
261
|
+
# independently when unit testing Parts and Scopes.
|
262
|
+
#
|
263
|
+
# @param format [Symbol] template format to use (defaults to the `default_format` setting)
|
264
|
+
# @param context [Context] context object to use (defaults to the `default_context` setting)
|
265
|
+
#
|
266
|
+
# @return [RenderEnvironment]
|
267
|
+
# @api public
|
268
|
+
def template_env(**args)
|
269
|
+
render_env(**args).chdir(config.template)
|
270
|
+
end
|
271
|
+
|
272
|
+
# @overload layout_env(format: config.default_format, context: config.default_context)
|
273
|
+
# Returns a render environment for the view and the given options,
|
274
|
+
# chdir'ed into the view's layout directory. This is the environment used
|
275
|
+
# when rendering the view's layout.
|
276
|
+
#
|
277
|
+
# @param format [Symbol] template format to use (defaults to the `default_format` setting)
|
278
|
+
# @param context [Context] context object to use (defaults to the `default_context` setting)
|
279
|
+
#
|
280
|
+
# @return [RenderEnvironment] @api public
|
281
|
+
def layout_env(**args)
|
282
|
+
render_env(**args).chdir(layout_path)
|
283
|
+
end
|
284
|
+
|
285
|
+
# Returns renderer for the view and provided format
|
286
|
+
#
|
287
|
+
# @api private
|
288
|
+
def renderer(format)
|
289
|
+
fetch_or_store(:renderer, config, format) {
|
290
|
+
Renderer.new(
|
291
|
+
config.paths,
|
292
|
+
format: format,
|
293
|
+
engine_mapping: config.renderer_engine_mapping,
|
294
|
+
**config.renderer_options
|
295
|
+
)
|
296
|
+
}
|
297
|
+
end
|
298
|
+
|
299
|
+
# @api private
|
300
|
+
def layout_path
|
301
|
+
File.join(*[config.layouts_dir, config.layout].compact)
|
302
|
+
end
|
303
|
+
|
304
|
+
# @!endgroup
|
305
|
+
end
|
306
|
+
|
307
|
+
module InstanceMethods
|
308
|
+
# Returns an instance of the view. This binds the defined exposures to the
|
309
|
+
# view instance.
|
310
|
+
#
|
311
|
+
# Subclasses can define their own `#initialize` to accept injected
|
312
|
+
# dependencies, but must call `super()` to ensure the standard view
|
313
|
+
# initialization can proceed.
|
314
|
+
#
|
315
|
+
# @api public
|
316
|
+
def initialize
|
317
|
+
@exposures = self.class.exposures.bind(self)
|
318
|
+
end
|
319
|
+
|
320
|
+
# The view's configuration
|
321
|
+
#
|
322
|
+
# @api private
|
323
|
+
def config
|
324
|
+
self.class.config
|
325
|
+
end
|
326
|
+
|
327
|
+
# The view's bound exposures
|
328
|
+
#
|
329
|
+
# @return [Exposures]
|
330
|
+
# @api private
|
331
|
+
def exposures
|
332
|
+
@exposures
|
333
|
+
end
|
334
|
+
|
335
|
+
# Render the view
|
336
|
+
#
|
337
|
+
# @param format [Symbol] template format to use
|
338
|
+
# @param context [Context] context object to use
|
339
|
+
# @param input input data for preparing exposure values
|
340
|
+
#
|
341
|
+
# @return [Rendered] rendered view object
|
342
|
+
# @api public
|
343
|
+
def call(format: config.default_format, context: config.default_context, **input)
|
344
|
+
ensure_config
|
345
|
+
|
346
|
+
env = self.class.render_env(format: format, context: context)
|
347
|
+
template_env = self.class.template_env(format: format, context: context)
|
348
|
+
|
349
|
+
locals = locals(template_env, input)
|
350
|
+
output = env.template(config.template, template_env.scope(config.scope, locals))
|
351
|
+
|
352
|
+
if layout?
|
353
|
+
layout_env = self.class.layout_env(format: format, context: context)
|
354
|
+
output = env.template(
|
355
|
+
self.class.layout_path,
|
356
|
+
layout_env.scope(config.scope, layout_locals(locals))
|
357
|
+
) { output }
|
358
|
+
end
|
359
|
+
|
360
|
+
Rendered.new(output: output, locals: locals)
|
361
|
+
end
|
362
|
+
|
363
|
+
private
|
364
|
+
|
365
|
+
# @api private
|
366
|
+
def ensure_config
|
367
|
+
raise UndefinedConfigError, :paths unless Array(config.paths).any?
|
368
|
+
raise UndefinedConfigError, :template unless config.template
|
369
|
+
end
|
370
|
+
|
371
|
+
# @api private
|
372
|
+
def locals(render_env, input)
|
373
|
+
exposures.(context: render_env.context, **input) do |value, exposure|
|
374
|
+
if exposure.decorate? && value
|
375
|
+
render_env.part(exposure.name, value, **exposure.options)
|
376
|
+
else
|
377
|
+
value
|
378
|
+
end
|
379
|
+
end
|
380
|
+
end
|
381
|
+
|
382
|
+
# @api private
|
383
|
+
def layout_locals(locals)
|
384
|
+
locals.each_with_object({}) do |(key, value), layout_locals|
|
385
|
+
layout_locals[key] = value if exposures[key].for_layout?
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
# @api private
|
390
|
+
def layout?
|
391
|
+
!!config.layout # rubocop:disable Style/DoubleNegation
|
392
|
+
end
|
393
|
+
end
|
394
|
+
end
|
395
|
+
end
|
396
|
+
end
|