hanami-view 1.3.1 → 2.0.0.alpha3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE +20 -0
  4. data/README.md +17 -835
  5. data/hanami-view.gemspec +26 -16
  6. data/lib/hanami/view/application_configuration.rb +77 -0
  7. data/lib/hanami/view/application_context.rb +35 -0
  8. data/lib/hanami/view/application_view.rb +89 -0
  9. data/lib/hanami/view/context.rb +97 -0
  10. data/lib/hanami/view/context_helpers/content_helpers.rb +26 -0
  11. data/lib/hanami/view/decorated_attributes.rb +82 -0
  12. data/lib/hanami/view/errors.rb +31 -53
  13. data/lib/hanami/view/exposure.rb +126 -0
  14. data/lib/hanami/view/exposures.rb +74 -0
  15. data/lib/hanami/view/part.rb +217 -0
  16. data/lib/hanami/view/part_builder.rb +140 -0
  17. data/lib/hanami/view/path.rb +68 -0
  18. data/lib/hanami/view/render_environment.rb +62 -0
  19. data/lib/hanami/view/render_environment_missing.rb +44 -0
  20. data/lib/hanami/view/rendered.rb +55 -0
  21. data/lib/hanami/view/renderer.rb +79 -0
  22. data/lib/hanami/view/scope.rb +189 -0
  23. data/lib/hanami/view/scope_builder.rb +98 -0
  24. data/lib/hanami/view/standalone_view.rb +400 -0
  25. data/lib/hanami/view/tilt/erb.rb +26 -0
  26. data/lib/hanami/view/tilt/erbse.rb +21 -0
  27. data/lib/hanami/view/tilt/haml.rb +26 -0
  28. data/lib/hanami/view/tilt.rb +78 -0
  29. data/lib/hanami/view/version.rb +5 -5
  30. data/lib/hanami/view.rb +208 -223
  31. data/lib/hanami-view.rb +3 -1
  32. metadata +120 -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/layout_finder.rb +0 -128
  41. data/lib/hanami/view/rendering/layout_registry.rb +0 -69
  42. data/lib/hanami/view/rendering/layout_scope.rb +0 -274
  43. data/lib/hanami/view/rendering/null_layout.rb +0 -52
  44. data/lib/hanami/view/rendering/null_local.rb +0 -82
  45. data/lib/hanami/view/rendering/null_template.rb +0 -83
  46. data/lib/hanami/view/rendering/null_view.rb +0 -26
  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/rendering.rb +0 -294
  61. data/lib/hanami/view/template.rb +0 -57
@@ -0,0 +1,400 @@
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
+ begin
355
+ output = env.template(
356
+ self.class.layout_path,
357
+ layout_env.scope(config.scope, layout_locals(locals))
358
+ ) { output }
359
+ rescue TemplateNotFoundError
360
+ raise LayoutNotFoundError.new(config.layout, config.paths)
361
+ end
362
+ end
363
+
364
+ Rendered.new(output: output, locals: locals)
365
+ end
366
+
367
+ private
368
+
369
+ # @api private
370
+ def ensure_config
371
+ raise UndefinedConfigError, :paths unless Array(config.paths).any?
372
+ raise UndefinedConfigError, :template unless config.template
373
+ end
374
+
375
+ # @api private
376
+ def locals(render_env, input)
377
+ exposures.(context: render_env.context, **input) do |value, exposure|
378
+ if exposure.decorate? && value
379
+ render_env.part(exposure.name, value, **exposure.options)
380
+ else
381
+ value
382
+ end
383
+ end
384
+ end
385
+
386
+ # @api private
387
+ def layout_locals(locals)
388
+ locals.each_with_object({}) do |(key, value), layout_locals|
389
+ layout_locals[key] = value if exposures[key].for_layout?
390
+ end
391
+ end
392
+
393
+ # @api private
394
+ def layout?
395
+ !!config.layout # rubocop:disable Style/DoubleNegation
396
+ end
397
+ end
398
+ end
399
+ end
400
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class View
5
+ module Tilt
6
+ module ERB
7
+ def self.requirements
8
+ ["hanami/view/tilt/erbse", <<~ERROR]
9
+ hanami-view requires erbse for full compatibility when rendering .erb templates (e.g. implicitly capturing block content when yielding)
10
+
11
+ To ignore this and use another engine for .erb templates, deregister this adapter before calling your views:
12
+
13
+ Hanami::View::Tilt.deregister_adapter(:erb)
14
+ ERROR
15
+ end
16
+
17
+ def self.activate
18
+ Tilt.default_mapping.register ErbseTemplate, "erb"
19
+ self
20
+ end
21
+ end
22
+
23
+ register_adapter :erb, ERB
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tilt/template"
4
+ require "erbse"
5
+
6
+ module Hanami
7
+ class View
8
+ module Tilt
9
+ # Tilt template class copied from cells-erb gem
10
+ class ErbseTemplate < ::Tilt::Template
11
+ def prepare
12
+ @template = ::Erbse::Engine.new
13
+ end
14
+
15
+ def precompiled_template(_locals)
16
+ @template.call(data)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class View
5
+ module Tilt
6
+ module Haml
7
+ def self.requirements
8
+ ["hamlit/block", <<~ERROR]
9
+ hanami-view requires hamlit-block for full compatibility when rendering .haml templates (e.g. implicitly capturing block content when yielding)
10
+
11
+ To ignore this and use another engine for .haml templates, dereigster this adapter before calling your views:
12
+
13
+ Hanami::View::Tilt.deregister_adatper(:haml)
14
+ ERROR
15
+ end
16
+
17
+ def self.activate
18
+ # Requiring hamlit/block will register the engine with Tilt
19
+ self
20
+ end
21
+ end
22
+
23
+ register_adapter :haml, Haml
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/cache"
4
+ require "tilt"
5
+
6
+ module Hanami
7
+ class View
8
+ # @api private
9
+ module Tilt
10
+ extend Dry::Core::Cache
11
+
12
+ class << self
13
+ def [](path, mapping, **options)
14
+ ext = File.extname(path).sub(/^./, "").to_sym
15
+ activate_adapter ext
16
+
17
+ with_mapping(mapping).new(path, **options)
18
+ end
19
+
20
+ def default_mapping
21
+ ::Tilt.default_mapping
22
+ end
23
+
24
+ def register_adapter(ext, adapter)
25
+ adapters[ext] = adapter
26
+ end
27
+
28
+ def deregister_adapter(ext)
29
+ adapters.delete(ext)
30
+ end
31
+
32
+ private
33
+
34
+ def adapters
35
+ @adapters ||= {}
36
+ end
37
+
38
+ def activate_adapter(ext)
39
+ fetch_or_store(:adapter, ext) {
40
+ adapter = adapters[ext]
41
+ return unless adapter
42
+
43
+ *requires, error_message = adapter.requirements
44
+
45
+ begin
46
+ requires.each(&method(:require))
47
+ rescue LoadError => e
48
+ raise e, "#{e.message}\n\n#{error_message}"
49
+ end
50
+
51
+ adapter.activate
52
+ }
53
+ end
54
+
55
+ def with_mapping(mapping)
56
+ fetch_or_store(:mapping, mapping) {
57
+ if mapping.any?
58
+ build_mapping(mapping)
59
+ else
60
+ default_mapping
61
+ end
62
+ }
63
+ end
64
+
65
+ def build_mapping(mapping)
66
+ default_mapping.dup.tap do |new_mapping|
67
+ mapping.each do |extension, template_class|
68
+ new_mapping.register template_class, extension
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ require_relative "tilt/erb"
78
+ require_relative "tilt/haml"
@@ -1,8 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Hanami
2
- module View
3
- # Defines the version
4
- #
5
- # @since 0.1.0
6
- VERSION = '1.3.1'.freeze
4
+ class View
5
+ # @api private
6
+ VERSION = "2.0.0.alpha3"
7
7
  end
8
8
  end