hanami-view 1.3.3 → 2.0.0.alpha2

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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -10
  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 +114 -70
  33. data/LICENSE.md +0 -22
  34. data/lib/hanami/layout.rb +0 -190
  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 -281
  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/null_view.rb +0 -26
  48. data/lib/hanami/view/rendering/options.rb +0 -24
  49. data/lib/hanami/view/rendering/partial.rb +0 -31
  50. data/lib/hanami/view/rendering/partial_file.rb +0 -29
  51. data/lib/hanami/view/rendering/partial_finder.rb +0 -75
  52. data/lib/hanami/view/rendering/partial_templates_finder.rb +0 -73
  53. data/lib/hanami/view/rendering/registry.rb +0 -134
  54. data/lib/hanami/view/rendering/scope.rb +0 -108
  55. data/lib/hanami/view/rendering/subscope.rb +0 -56
  56. data/lib/hanami/view/rendering/template.rb +0 -69
  57. data/lib/hanami/view/rendering/template_finder.rb +0 -55
  58. data/lib/hanami/view/rendering/template_name.rb +0 -50
  59. data/lib/hanami/view/rendering/templates_finder.rb +0 -144
  60. data/lib/hanami/view/rendering/view_finder.rb +0 -37
  61. 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