hanami-view 2.0.0.alpha7 → 2.0.0.alpha8

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f1426ce046073c71aeffe9a171b0b0343704686bb92b75046526858ca82f9e8
4
- data.tar.gz: 14a7f4078a42e3abd63e5411da1a2fa4b19fdfeaf405184c9c1f11bda7e78210
3
+ metadata.gz: d49c664876b44d1be9f65e7d9c472a3fe50ab40d42002b170a4a2121ddf2f095
4
+ data.tar.gz: 51508250526dd6af93f2f8d1af2031e37c39109896a0b92fac038b99a65e1512
5
5
  SHA512:
6
- metadata.gz: 1a0f2cca15e556ee2c93396f730c6b68d3a7799ec0c2cbebc574b5c73c0315d2192c89ad8d85ad1b273d03b80132b3acf2348befab3c7f22fff28b1ef7e82667
7
- data.tar.gz: 5032a5b1c0891a369bf518cf12d0baad00dcfb688ac41625dd61818b635b8ef6dea498a574c9558249a0beb2cd905553b253bb5a0a8b82245030664d0f7bd629
6
+ metadata.gz: e08aff9c75a5c412922f6e7669078e0a88258d8aa6d80b7b56569ce3732a15e703861e7a713381c1e928cbe5e5a67f1ac51e2473a9de85868ca9c895184e838e
7
+ data.tar.gz: cdad93f677870279dad0fb101dbfc03b4da79d78f78f3f1d6de5fdc50b7e7b0f4e69aef6b9b360049aaa2ff45d2e15beb24b2afe6116a87c07a17eb9eb0219eb
data/CHANGELOG.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Hanami::View
2
2
  View layer for Hanami
3
3
 
4
+ ## v2.0.0.alpha8 - 2022-05-19
5
+
6
+ ### Added
7
+ - [Tim Riley] Access a hash of template locals via accessing `locals` inside any template
8
+
9
+ ### Changed
10
+ - [Tim Riley] Removed automatic integration of `Hanami::View` subclasses with their surrounding Hanami application. View base classes within Hanami apps should inherit from `Hanami::Application::View` instead.
11
+
4
12
  ## v2.0.0.alpha7 - 2022-03-08
5
13
 
6
14
  ### Added
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/core/equalizer"
4
- require_relative "application_context"
5
4
  require_relative "decorated_attributes"
6
5
 
7
6
  module Hanami
@@ -19,23 +18,6 @@ module Hanami
19
18
 
20
19
  attr_reader :_render_env, :_options
21
20
 
22
- def self.inherited(subclass)
23
- super
24
-
25
- # When inheriting within an Hanami app, add application context behavior
26
- provider = application_provider(subclass)
27
- if provider
28
- subclass.include ApplicationContext.new(provider)
29
- end
30
- end
31
-
32
- def self.application_provider(subclass)
33
- if Hanami.respond_to?(:application?) && Hanami.application?
34
- Hanami.application.component_provider(subclass)
35
- end
36
- end
37
- private_class_method :application_provider
38
-
39
21
  # Returns a new instance of Context
40
22
  #
41
23
  # In subclasses, you should include an `**options` parameter and pass _all
@@ -45,13 +45,5 @@ module Hanami
45
45
  super(msg)
46
46
  end
47
47
  end
48
-
49
- # @since 2.0.0
50
- # @api public
51
- class MissingProviderError < Error
52
- def initialize(provider)
53
- super("#{provider.inspect} is missing")
54
- end
55
- end
56
48
  end
57
49
  end
@@ -46,7 +46,7 @@ module Hanami
46
46
  end
47
47
 
48
48
  def render(path, scope, &block)
49
- tilt(path).render(scope, &block)
49
+ tilt(path).render(scope, {locals: scope._locals}, &block)
50
50
  end
51
51
 
52
52
  def chdir(dirname)
@@ -35,7 +35,7 @@ module Hanami
35
35
  # @overload _locals
36
36
  # Returns the locals
37
37
  # @overload locals
38
- # A convenience alias for `#_format.` Is available unless there is a
38
+ # A convenience alias for `#_locals.` Is available unless there is a
39
39
  # local named `locals`
40
40
  #
41
41
  # @return [Hash[<Symbol, Object>]
@@ -3,6 +3,6 @@
3
3
  module Hanami
4
4
  class View
5
5
  # @api private
6
- VERSION = "2.0.0.alpha7"
6
+ VERSION = "2.0.0.alpha8"
7
7
  end
8
8
  end
data/lib/hanami/view.rb CHANGED
@@ -15,7 +15,6 @@ require_relative "view/render_environment"
15
15
  require_relative "view/rendered"
16
16
  require_relative "view/renderer"
17
17
  require_relative "view/scope_builder"
18
- require_relative "view/standalone_view"
19
18
 
20
19
  module Hanami
21
20
  # A standalone, template-based view rendering system that offers everything
@@ -230,24 +229,388 @@ module Hanami
230
229
 
231
230
  # @!endgroup
232
231
 
233
- include StandaloneView
234
-
235
- def self.inherited(subclass)
232
+ # @api private
233
+ def self.inherited(klass)
236
234
  super
237
235
 
238
- # When inheriting within an Hanami app, and the application provider has
239
- # changed from the superclass, (re-)configure the action for the provider,
240
- # i.e. for the slice and/or the application itself
241
- if (provider = application_provider(subclass)) && provider != application_provider(subclass.superclass)
242
- subclass.include ApplicationView.new(provider)
236
+ exposures.each do |name, exposure|
237
+ klass.exposures.import(name, exposure)
243
238
  end
244
239
  end
245
240
 
246
- def self.application_provider(subclass)
247
- if Hanami.respond_to?(:application?) && Hanami.application?
248
- Hanami.application.component_provider(subclass)
241
+ # @!group Exposures
242
+
243
+ # @!macro [new] exposure_options
244
+ # @param options [Hash] the exposure's options
245
+ # @option options [Boolean] :layout expose this value to the layout (defaults to false)
246
+ # @option options [Boolean] :decorate decorate this value in a matching Part (defaults to
247
+ # true)
248
+ # @option options [Symbol, Class] :as an alternative name or class to use when finding a
249
+ # matching Part
250
+
251
+ # @overload expose(name, **options, &block)
252
+ # Define a value to be passed to the template. The return value of the
253
+ # block will be decorated by a matching Part and passed to the template.
254
+ #
255
+ # The block will be evaluated with the view instance as its `self`. The
256
+ # block's parameters will determine what it is given:
257
+ #
258
+ # - To receive other exposure values, provide positional parameters
259
+ # matching the exposure names. These exposures will already by decorated
260
+ # by their Parts.
261
+ # - To receive the view's input arguments (whatever is passed to
262
+ # `View#call`), provide matching keyword parameters. You can provide
263
+ # default values for these parameters to make the corresponding input
264
+ # keys optional
265
+ # - To receive the Context object, provide a `context:` keyword parameter
266
+ # - To receive the view's input arguments in their entirety, provide a
267
+ # keywords splat parameter (i.e. `**input`)
268
+ #
269
+ # @example Accessing input arguments
270
+ # expose :article do |slug:|
271
+ # article_repo.find_by_slug(slug)
272
+ # end
273
+ #
274
+ # @example Accessing other exposures
275
+ # expose :articles do
276
+ # article_repo.listing
277
+ # end
278
+ #
279
+ # expose :featured_articles do |articles|
280
+ # articles.select(&:featured?)
281
+ # end
282
+ #
283
+ # @param name [Symbol] name for the exposure
284
+ # @macro exposure_options
285
+ #
286
+ # @overload expose(name, **options)
287
+ # Define a value to be passed to the template, provided by an instance
288
+ # method matching the name. The method's return value will be decorated by
289
+ # a matching Part and passed to the template.
290
+ #
291
+ # The method's parameters will determine what it is given:
292
+ #
293
+ # - To receive other exposure values, provide positional parameters
294
+ # matching the exposure names. These exposures will already by decorated
295
+ # by their Parts.
296
+ # - To receive the view's input arguments (whatever is passed to
297
+ # `View#call`), provide matching keyword parameters. You can provide
298
+ # default values for these parameters to make the corresponding input
299
+ # keys optional
300
+ # - To receive the Context object, provide a `context:` keyword parameter
301
+ # - To receive the view's input arguments in their entirey, provide a
302
+ # keywords splat parameter (i.e. `**input`)
303
+ #
304
+ # @example Accessing input arguments
305
+ # expose :article
306
+ #
307
+ # def article(slug:)
308
+ # article_repo.find_by_slug(slug)
309
+ # end
310
+ #
311
+ # @example Accessing other exposures
312
+ # expose :articles
313
+ # expose :featured_articles
314
+ #
315
+ # def articles
316
+ # article_repo.listing
317
+ # end
318
+ #
319
+ # def featured_articles
320
+ # articles.select(&:featured?)
321
+ # end
322
+ #
323
+ # @param name [Symbol] name for the exposure
324
+ # @macro exposure_options
325
+ #
326
+ # @overload expose(name, **options)
327
+ # Define a single value to pass through from the input data (when there is
328
+ # no instance method matching the `name`). This value will be decorated by
329
+ # a matching Part and passed to the template.
330
+ #
331
+ # @param name [Symbol] name for the exposure
332
+ # @macro exposure_options
333
+ # @option options [Boolean] :default a default value to provide if there is no matching
334
+ # input data
335
+ #
336
+ # @overload expose(*names, **options)
337
+ # Define multiple values to pass through from the input data (when there
338
+ # is no instance methods matching their names). These values will be
339
+ # decorated by matching Parts and passed through to the template.
340
+ #
341
+ # The provided options will be applied to all the exposures.
342
+ #
343
+ # @param names [Symbol] names for the exposures
344
+ # @macro exposure_options
345
+ # @option options [Boolean] :default a default value to provide if there is no matching
346
+ # input data
347
+ #
348
+ # @see https://dry-rb.org/gems/dry-view/exposures/
349
+ #
350
+ # @api public
351
+ def self.expose(*names, **options, &block)
352
+ if names.length == 1
353
+ exposures.add(names.first, block, **options)
354
+ else
355
+ names.each do |name|
356
+ exposures.add(name, **options)
357
+ end
249
358
  end
250
359
  end
251
- private_class_method :application_provider
360
+
361
+ # @api public
362
+ def self.private_expose(*names, **options, &block)
363
+ expose(*names, **options, private: true, &block)
364
+ end
365
+
366
+ # Returns the defined exposures. These are unbound, since bound exposures
367
+ # are only created when initializing a View instance.
368
+ #
369
+ # @return [Exposures]
370
+ # @api private
371
+ def self.exposures
372
+ @exposures ||= Exposures.new
373
+ end
374
+
375
+ # @!endgroup
376
+
377
+ # @!group Scope
378
+
379
+ # Creates and assigns a scope for the current view.
380
+ #
381
+ # The newly created scope is useful to add custom logic that is specific
382
+ # to the view.
383
+ #
384
+ # The scope has access to locals, exposures, and inherited scope (if any)
385
+ #
386
+ # If the view already has an explicit scope the newly created scope will
387
+ # inherit from the explicit scope.
388
+ #
389
+ # There are two cases when this may happen:
390
+ # 1. The scope was explicitly assigned (e.g. `config.scope = MyScope`)
391
+ # 2. The scope has been inherited by the view superclass
392
+ #
393
+ # If the view doesn't have an already existing scope, the newly scope
394
+ # will inherit from `Hanami::View::Scope` by default.
395
+ #
396
+ # However, you can specify any base class for it. This is not
397
+ # recommended, unless you know what you're doing.
398
+ #
399
+ # @param scope [Hanami::View::Scope] the current scope (if any), or the
400
+ # default base class will be `Hanami::View::Scope`
401
+ # @param block [Proc] the scope logic definition
402
+ #
403
+ # @api public
404
+ #
405
+ # @example Basic usage
406
+ # class MyView < Hanami::View
407
+ # config.scope = MyScope
408
+ #
409
+ # scope do
410
+ # def greeting
411
+ # _locals[:message].upcase + "!"
412
+ # end
413
+ #
414
+ # def copyright(time)
415
+ # "Copy #{time.year}"
416
+ # end
417
+ # end
418
+ # end
419
+ #
420
+ # # my_view.html.erb
421
+ # # <%= greeting %>
422
+ # # <%= copyright(Time.now.utc) %>
423
+ #
424
+ # MyView.new.(message: "Hello") # => "HELLO!"
425
+ #
426
+ # @example Inherited scope
427
+ # class MyScope < Hanami::View::Scope
428
+ # private
429
+ #
430
+ # def shout(string)
431
+ # string.upcase + "!"
432
+ # end
433
+ # end
434
+ #
435
+ # class MyView < Hanami::View
436
+ # config.scope = MyScope
437
+ #
438
+ # scope do
439
+ # def greeting
440
+ # shout(_locals[:message])
441
+ # end
442
+ #
443
+ # def copyright(time)
444
+ # "Copy #{time.year}"
445
+ # end
446
+ # end
447
+ # end
448
+ #
449
+ # # my_view.html.erb
450
+ # # <%= greeting %>
451
+ # # <%= copyright(Time.now.utc) %>
452
+ #
453
+ # MyView.new.(message: "Hello") # => "HELLO!"
454
+ def self.scope(base: config.scope || Hanami::View::Scope, &block)
455
+ config.scope = Class.new(base, &block)
456
+ end
457
+
458
+ # @!endgroup
459
+
460
+ # @!group Render environment
461
+
462
+ # Returns a render environment for the view and the given options. This
463
+ # environment isn't chdir'ed into any particular directory.
464
+ #
465
+ # @param format [Symbol] template format to use (defaults to the `default_format` setting)
466
+ # @param context [Context] context object to use (defaults to the `default_context` setting)
467
+ #
468
+ # @see View.template_env render environment for the view's template
469
+ # @see View.layout_env render environment for the view's layout
470
+ #
471
+ # @return [RenderEnvironment]
472
+ # @api public
473
+ def self.render_env(format: config.default_format, context: config.default_context)
474
+ RenderEnvironment.prepare(renderer(format), config, context)
475
+ end
476
+
477
+ # @overload template_env(format: config.default_format, context: config.default_context)
478
+ # Returns a render environment for the view and the given options,
479
+ # chdir'ed into the view's template directory. This is the environment
480
+ # used when rendering the template, and is useful to to fetch
481
+ # independently when unit testing Parts and Scopes.
482
+ #
483
+ # @param format [Symbol] template format to use (defaults to the `default_format` setting)
484
+ # @param context [Context] context object to use (defaults to the `default_context` setting)
485
+ #
486
+ # @return [RenderEnvironment]
487
+ # @api public
488
+ def self.template_env(**args)
489
+ render_env(**args).chdir(config.template)
490
+ end
491
+
492
+ # @overload layout_env(format: config.default_format, context: config.default_context)
493
+ # Returns a render environment for the view and the given options,
494
+ # chdir'ed into the view's layout directory. This is the environment used
495
+ # when rendering the view's layout.
496
+ #
497
+ # @param format [Symbol] template format to use (defaults to the `default_format` setting)
498
+ # @param context [Context] context object to use (defaults to the `default_context` setting)
499
+ #
500
+ # @return [RenderEnvironment] @api public
501
+ def self.layout_env(**args)
502
+ render_env(**args).chdir(layout_path)
503
+ end
504
+
505
+ # Returns renderer for the view and provided format
506
+ #
507
+ # @api private
508
+ def self.renderer(format)
509
+ fetch_or_store(:renderer, config, format) {
510
+ Renderer.new(
511
+ config.paths,
512
+ format: format,
513
+ engine_mapping: config.renderer_engine_mapping,
514
+ **config.renderer_options
515
+ )
516
+ }
517
+ end
518
+
519
+ # @api private
520
+ def self.layout_path
521
+ File.join(*[config.layouts_dir, config.layout].compact)
522
+ end
523
+
524
+ # @!endgroup
525
+
526
+ # Returns an instance of the view. This binds the defined exposures to the
527
+ # view instance.
528
+ #
529
+ # Subclasses can define their own `#initialize` to accept injected
530
+ # dependencies, but must call `super()` to ensure the standard view
531
+ # initialization can proceed.
532
+ #
533
+ # @api public
534
+ def initialize
535
+ @exposures = self.class.exposures.bind(self)
536
+ end
537
+
538
+ # The view's configuration
539
+ #
540
+ # @api private
541
+ def config
542
+ self.class.config
543
+ end
544
+
545
+ # The view's bound exposures
546
+ #
547
+ # @return [Exposures]
548
+ # @api private
549
+ def exposures
550
+ @exposures
551
+ end
552
+
553
+ # Render the view
554
+ #
555
+ # @param format [Symbol] template format to use
556
+ # @param context [Context] context object to use
557
+ # @param input input data for preparing exposure values
558
+ #
559
+ # @return [Rendered] rendered view object
560
+ # @api public
561
+ def call(format: config.default_format, context: config.default_context, **input)
562
+ ensure_config
563
+
564
+ env = self.class.render_env(format: format, context: context)
565
+ template_env = self.class.template_env(format: format, context: context)
566
+
567
+ locals = locals(template_env, input)
568
+ output = env.template(config.template, template_env.scope(config.scope, locals))
569
+
570
+ if layout?
571
+ layout_env = self.class.layout_env(format: format, context: context)
572
+ begin
573
+ output = env.template(
574
+ self.class.layout_path,
575
+ layout_env.scope(config.scope, layout_locals(locals))
576
+ ) { output }
577
+ rescue TemplateNotFoundError
578
+ raise LayoutNotFoundError.new(config.layout, config.paths)
579
+ end
580
+ end
581
+
582
+ Rendered.new(output: output, locals: locals)
583
+ end
584
+
585
+ private
586
+
587
+ # @api private
588
+ def ensure_config
589
+ raise UndefinedConfigError, :paths unless Array(config.paths).any?
590
+ raise UndefinedConfigError, :template unless config.template
591
+ end
592
+
593
+ # @api private
594
+ def locals(render_env, input)
595
+ exposures.(context: render_env.context, **input) do |value, exposure|
596
+ if exposure.decorate? && value
597
+ render_env.part(exposure.name, value, **exposure.options)
598
+ else
599
+ value
600
+ end
601
+ end
602
+ end
603
+
604
+ # @api private
605
+ def layout_locals(locals)
606
+ locals.each_with_object({}) do |(key, value), layout_locals|
607
+ layout_locals[key] = value if exposures[key].for_layout?
608
+ end
609
+ end
610
+
611
+ # @api private
612
+ def layout?
613
+ !!config.layout # rubocop:disable Style/DoubleNegation
614
+ end
252
615
  end
253
616
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hanami-view
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.alpha7
4
+ version: 2.0.0.alpha8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tim Riley
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-03-08 00:00:00.000000000 Z
12
+ date: 2022-05-19 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: concurrent-ruby
@@ -156,8 +156,6 @@ files:
156
156
  - hanami-view.gemspec
157
157
  - lib/hanami-view.rb
158
158
  - lib/hanami/view.rb
159
- - lib/hanami/view/application_configuration.rb
160
- - lib/hanami/view/application_context.rb
161
159
  - lib/hanami/view/application_view.rb
162
160
  - lib/hanami/view/context.rb
163
161
  - lib/hanami/view/context_helpers/content_helpers.rb
@@ -174,7 +172,6 @@ files:
174
172
  - lib/hanami/view/renderer.rb
175
173
  - lib/hanami/view/scope.rb
176
174
  - lib/hanami/view/scope_builder.rb
177
- - lib/hanami/view/standalone_view.rb
178
175
  - lib/hanami/view/tilt.rb
179
176
  - lib/hanami/view/tilt/erb.rb
180
177
  - lib/hanami/view/tilt/erbse.rb
@@ -204,7 +201,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
204
201
  - !ruby/object:Gem::Version
205
202
  version: 1.3.1
206
203
  requirements: []
207
- rubygems_version: 3.3.3
204
+ rubygems_version: 3.3.7
208
205
  signing_key:
209
206
  specification_version: 4
210
207
  summary: A complete, standalone view rendering system that gives you everything you
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/configurable"
4
- require_relative "../view"
5
-
6
- module Hanami
7
- class View
8
- class ApplicationConfiguration
9
- include Dry::Configurable
10
-
11
- setting :parts_path, default: "view/parts"
12
-
13
- def initialize(*)
14
- super
15
-
16
- @base_config = View.config.dup
17
-
18
- configure_defaults
19
- end
20
-
21
- # Returns the list of available settings
22
- #
23
- # @return [Set]
24
- #
25
- # @since 2.0.0
26
- # @api private
27
- def settings
28
- self.class.settings + View.settings - NON_FORWARDABLE_METHODS
29
- end
30
-
31
- def finalize!
32
- return self if frozen?
33
-
34
- base_config.finalize!
35
-
36
- super
37
- end
38
-
39
- private
40
-
41
- attr_reader :base_config
42
-
43
- def configure_defaults
44
- self.paths = ["templates"]
45
- self.template_inference_base = "views"
46
- self.layout = "application"
47
- end
48
-
49
- # An inflector for views is not configurable via `config.views.inflector` on an
50
- # `Hanami::Application`. The application-wide inflector is already configurable
51
- # there as `config.inflector` and will be used as the default inflector for views.
52
- #
53
- # A custom inflector may still be provided in an `Hanami::View` subclass, via
54
- # `config.inflector=`.
55
- NON_FORWARDABLE_METHODS = [:inflector, :inflector=].freeze
56
- private_constant :NON_FORWARDABLE_METHODS
57
-
58
- def method_missing(name, *args, &block)
59
- return super if NON_FORWARDABLE_METHODS.include?(name)
60
-
61
- if config.respond_to?(name)
62
- config.public_send(name, *args, &block)
63
- elsif base_config.respond_to?(name)
64
- base_config.public_send(name, *args, &block)
65
- else
66
- super
67
- end
68
- end
69
-
70
- def respond_to_missing?(name, _include_all = false)
71
- return false if NON_FORWARDABLE_METHODS.include?(name)
72
-
73
- config.respond_to?(name) || base_config.respond_to?(name) || super
74
- end
75
- end
76
- end
77
- end
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "hanami/view/errors"
4
-
5
- module Hanami
6
- class View
7
- class ApplicationContext < Module
8
- attr_reader :provider
9
- attr_reader :application
10
-
11
- def initialize(provider)
12
- @provider = provider
13
- @application = provider.respond_to?(:application) ? provider.application : Hanami.application
14
- end
15
-
16
- def included(context_class)
17
- define_initialize
18
- context_class.include(InstanceMethods)
19
- end
20
-
21
- private
22
-
23
- def define_initialize
24
- inflector = application.inflector
25
- settings = application[:settings] if application.key?(:settings)
26
- routes = application[:routes_helper] if application.key?(:routes_helper)
27
- assets = application[:assets] if application.key?(:assets)
28
-
29
- define_method :initialize do |**options|
30
- @inflector = options[:inflector] || inflector
31
- @settings = options[:settings] || settings
32
- @routes = options[:routes] || routes
33
- @assets = options[:assets] || assets
34
- super(**options)
35
- end
36
- end
37
-
38
- module InstanceMethods
39
- attr_reader :inflector
40
- attr_reader :routes
41
- attr_reader :settings
42
-
43
- def initialize(**args)
44
- defaults = {content: {}}
45
-
46
- super(**defaults.merge(args))
47
- end
48
-
49
- def content_for(key, value = nil, &block)
50
- content = _options[:content]
51
- output = nil
52
-
53
- if block
54
- content[key] = yield
55
- elsif value
56
- content[key] = value
57
- else
58
- output = content[key]
59
- end
60
-
61
- output
62
- end
63
-
64
- def current_path
65
- request.fullpath
66
- end
67
-
68
- def csrf_token
69
- request.session[Hanami::Action::CSRFProtection::CSRF_TOKEN]
70
- end
71
-
72
- def request
73
- _options.fetch(:request)
74
- end
75
-
76
- def session
77
- request.session
78
- end
79
-
80
- def flash
81
- response.flash
82
- end
83
-
84
- def assets
85
- @assets or
86
- raise Hanami::View::MissingProviderError.new("hanami-assets")
87
- end
88
-
89
- private
90
-
91
- # TODO: create `Request#flash` so we no longer need the `response`
92
- def response
93
- _options.fetch(:response)
94
- end
95
- end
96
- end
97
- end
98
- end
@@ -1,400 +0,0 @@
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