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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE +20 -0
  4. data/README.md +20 -862
  5. data/hanami-view.gemspec +26 -16
  6. data/lib/hanami-view.rb +3 -1
  7. data/lib/hanami/view.rb +208 -223
  8. data/lib/hanami/view/application_configuration.rb +77 -0
  9. data/lib/hanami/view/application_context.rb +35 -0
  10. data/lib/hanami/view/application_view.rb +89 -0
  11. data/lib/hanami/view/context.rb +97 -0
  12. data/lib/hanami/view/context_helpers/content_helpers.rb +26 -0
  13. data/lib/hanami/view/decorated_attributes.rb +82 -0
  14. data/lib/hanami/view/errors.rb +19 -56
  15. data/lib/hanami/view/exposure.rb +126 -0
  16. data/lib/hanami/view/exposures.rb +74 -0
  17. data/lib/hanami/view/part.rb +217 -0
  18. data/lib/hanami/view/part_builder.rb +140 -0
  19. data/lib/hanami/view/path.rb +68 -0
  20. data/lib/hanami/view/render_environment.rb +62 -0
  21. data/lib/hanami/view/render_environment_missing.rb +44 -0
  22. data/lib/hanami/view/rendered.rb +55 -0
  23. data/lib/hanami/view/renderer.rb +79 -0
  24. data/lib/hanami/view/scope.rb +189 -0
  25. data/lib/hanami/view/scope_builder.rb +98 -0
  26. data/lib/hanami/view/standalone_view.rb +396 -0
  27. data/lib/hanami/view/tilt.rb +78 -0
  28. data/lib/hanami/view/tilt/erb.rb +26 -0
  29. data/lib/hanami/view/tilt/erbse.rb +21 -0
  30. data/lib/hanami/view/tilt/haml.rb +26 -0
  31. data/lib/hanami/view/version.rb +5 -5
  32. metadata +113 -63
  33. data/LICENSE.md +0 -22
  34. data/lib/hanami/layout.rb +0 -172
  35. data/lib/hanami/presenter.rb +0 -98
  36. data/lib/hanami/view/configuration.rb +0 -504
  37. data/lib/hanami/view/dsl.rb +0 -347
  38. data/lib/hanami/view/escape.rb +0 -225
  39. data/lib/hanami/view/inheritable.rb +0 -54
  40. data/lib/hanami/view/rendering.rb +0 -294
  41. data/lib/hanami/view/rendering/layout_finder.rb +0 -128
  42. data/lib/hanami/view/rendering/layout_registry.rb +0 -69
  43. data/lib/hanami/view/rendering/layout_scope.rb +0 -274
  44. data/lib/hanami/view/rendering/null_layout.rb +0 -52
  45. data/lib/hanami/view/rendering/null_local.rb +0 -82
  46. data/lib/hanami/view/rendering/null_template.rb +0 -83
  47. data/lib/hanami/view/rendering/options.rb +0 -24
  48. data/lib/hanami/view/rendering/partial.rb +0 -31
  49. data/lib/hanami/view/rendering/partial_file.rb +0 -29
  50. data/lib/hanami/view/rendering/partial_finder.rb +0 -75
  51. data/lib/hanami/view/rendering/partial_templates_finder.rb +0 -73
  52. data/lib/hanami/view/rendering/registry.rb +0 -134
  53. data/lib/hanami/view/rendering/scope.rb +0 -108
  54. data/lib/hanami/view/rendering/subscope.rb +0 -56
  55. data/lib/hanami/view/rendering/template.rb +0 -69
  56. data/lib/hanami/view/rendering/template_finder.rb +0 -55
  57. data/lib/hanami/view/rendering/template_name.rb +0 -50
  58. data/lib/hanami/view/rendering/templates_finder.rb +0 -144
  59. data/lib/hanami/view/rendering/view_finder.rb +0 -37
  60. 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