hanami-view 2.0.0.alpha7 → 2.0.0.alpha8

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.
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