lib/hanami/view.rb in hanami-view-2.0.0.alpha7 vs lib/hanami/view.rb in hanami-view-2.0.0.alpha8
- old
+ new
@@ -13,11 +13,10 @@
require_relative "view/path"
require_relative "view/render_environment"
require_relative "view/rendered"
require_relative "view/renderer"
require_relative "view/scope_builder"
-require_relative "view/standalone_view"
module Hanami
# A standalone, template-based view rendering system that offers everything
# you need to write well-factored view code.
#
@@ -228,26 +227,390 @@
# @!scope class
setting :renderer_engine_mapping
# @!endgroup
- include StandaloneView
-
- def self.inherited(subclass)
+ # @api private
+ def self.inherited(klass)
super
- # When inheriting within an Hanami app, and the application provider has
- # changed from the superclass, (re-)configure the action for the provider,
- # i.e. for the slice and/or the application itself
- if (provider = application_provider(subclass)) && provider != application_provider(subclass.superclass)
- subclass.include ApplicationView.new(provider)
+ exposures.each do |name, exposure|
+ klass.exposures.import(name, exposure)
end
end
- def self.application_provider(subclass)
- if Hanami.respond_to?(:application?) && Hanami.application?
- Hanami.application.component_provider(subclass)
+ # @!group Exposures
+
+ # @!macro [new] exposure_options
+ # @param options [Hash] the exposure's options
+ # @option options [Boolean] :layout expose this value to the layout (defaults to false)
+ # @option options [Boolean] :decorate decorate this value in a matching Part (defaults to
+ # true)
+ # @option options [Symbol, Class] :as an alternative name or class to use when finding a
+ # matching Part
+
+ # @overload expose(name, **options, &block)
+ # Define a value to be passed to the template. The return value of the
+ # block will be decorated by a matching Part and passed to the template.
+ #
+ # The block will be evaluated with the view instance as its `self`. The
+ # block's parameters will determine what it is given:
+ #
+ # - To receive other exposure values, provide positional parameters
+ # matching the exposure names. These exposures will already by decorated
+ # by their Parts.
+ # - To receive the view's input arguments (whatever is passed to
+ # `View#call`), provide matching keyword parameters. You can provide
+ # default values for these parameters to make the corresponding input
+ # keys optional
+ # - To receive the Context object, provide a `context:` keyword parameter
+ # - To receive the view's input arguments in their entirety, provide a
+ # keywords splat parameter (i.e. `**input`)
+ #
+ # @example Accessing input arguments
+ # expose :article do |slug:|
+ # article_repo.find_by_slug(slug)
+ # end
+ #
+ # @example Accessing other exposures
+ # expose :articles do
+ # article_repo.listing
+ # end
+ #
+ # expose :featured_articles do |articles|
+ # articles.select(&:featured?)
+ # end
+ #
+ # @param name [Symbol] name for the exposure
+ # @macro exposure_options
+ #
+ # @overload expose(name, **options)
+ # Define a value to be passed to the template, provided by an instance
+ # method matching the name. The method's return value will be decorated by
+ # a matching Part and passed to the template.
+ #
+ # The method's parameters will determine what it is given:
+ #
+ # - To receive other exposure values, provide positional parameters
+ # matching the exposure names. These exposures will already by decorated
+ # by their Parts.
+ # - To receive the view's input arguments (whatever is passed to
+ # `View#call`), provide matching keyword parameters. You can provide
+ # default values for these parameters to make the corresponding input
+ # keys optional
+ # - To receive the Context object, provide a `context:` keyword parameter
+ # - To receive the view's input arguments in their entirey, provide a
+ # keywords splat parameter (i.e. `**input`)
+ #
+ # @example Accessing input arguments
+ # expose :article
+ #
+ # def article(slug:)
+ # article_repo.find_by_slug(slug)
+ # end
+ #
+ # @example Accessing other exposures
+ # expose :articles
+ # expose :featured_articles
+ #
+ # def articles
+ # article_repo.listing
+ # end
+ #
+ # def featured_articles
+ # articles.select(&:featured?)
+ # end
+ #
+ # @param name [Symbol] name for the exposure
+ # @macro exposure_options
+ #
+ # @overload expose(name, **options)
+ # Define a single value to pass through from the input data (when there is
+ # no instance method matching the `name`). This value will be decorated by
+ # a matching Part and passed to the template.
+ #
+ # @param name [Symbol] name for the exposure
+ # @macro exposure_options
+ # @option options [Boolean] :default a default value to provide if there is no matching
+ # input data
+ #
+ # @overload expose(*names, **options)
+ # Define multiple values to pass through from the input data (when there
+ # is no instance methods matching their names). These values will be
+ # decorated by matching Parts and passed through to the template.
+ #
+ # The provided options will be applied to all the exposures.
+ #
+ # @param names [Symbol] names for the exposures
+ # @macro exposure_options
+ # @option options [Boolean] :default a default value to provide if there is no matching
+ # input data
+ #
+ # @see https://dry-rb.org/gems/dry-view/exposures/
+ #
+ # @api public
+ def self.expose(*names, **options, &block)
+ if names.length == 1
+ exposures.add(names.first, block, **options)
+ else
+ names.each do |name|
+ exposures.add(name, **options)
+ end
end
end
- private_class_method :application_provider
+
+ # @api public
+ def self.private_expose(*names, **options, &block)
+ expose(*names, **options, private: true, &block)
+ end
+
+ # Returns the defined exposures. These are unbound, since bound exposures
+ # are only created when initializing a View instance.
+ #
+ # @return [Exposures]
+ # @api private
+ def self.exposures
+ @exposures ||= Exposures.new
+ end
+
+ # @!endgroup
+
+ # @!group Scope
+
+ # Creates and assigns a scope for the current view.
+ #
+ # The newly created scope is useful to add custom logic that is specific
+ # to the view.
+ #
+ # The scope has access to locals, exposures, and inherited scope (if any)
+ #
+ # If the view already has an explicit scope the newly created scope will
+ # inherit from the explicit scope.
+ #
+ # There are two cases when this may happen:
+ # 1. The scope was explicitly assigned (e.g. `config.scope = MyScope`)
+ # 2. The scope has been inherited by the view superclass
+ #
+ # If the view doesn't have an already existing scope, the newly scope
+ # will inherit from `Hanami::View::Scope` by default.
+ #
+ # However, you can specify any base class for it. This is not
+ # recommended, unless you know what you're doing.
+ #
+ # @param scope [Hanami::View::Scope] the current scope (if any), or the
+ # default base class will be `Hanami::View::Scope`
+ # @param block [Proc] the scope logic definition
+ #
+ # @api public
+ #
+ # @example Basic usage
+ # class MyView < Hanami::View
+ # config.scope = MyScope
+ #
+ # scope do
+ # def greeting
+ # _locals[:message].upcase + "!"
+ # end
+ #
+ # def copyright(time)
+ # "Copy #{time.year}"
+ # end
+ # end
+ # end
+ #
+ # # my_view.html.erb
+ # # <%= greeting %>
+ # # <%= copyright(Time.now.utc) %>
+ #
+ # MyView.new.(message: "Hello") # => "HELLO!"
+ #
+ # @example Inherited scope
+ # class MyScope < Hanami::View::Scope
+ # private
+ #
+ # def shout(string)
+ # string.upcase + "!"
+ # end
+ # end
+ #
+ # class MyView < Hanami::View
+ # config.scope = MyScope
+ #
+ # scope do
+ # def greeting
+ # shout(_locals[:message])
+ # end
+ #
+ # def copyright(time)
+ # "Copy #{time.year}"
+ # end
+ # end
+ # end
+ #
+ # # my_view.html.erb
+ # # <%= greeting %>
+ # # <%= copyright(Time.now.utc) %>
+ #
+ # MyView.new.(message: "Hello") # => "HELLO!"
+ def self.scope(base: config.scope || Hanami::View::Scope, &block)
+ config.scope = Class.new(base, &block)
+ end
+
+ # @!endgroup
+
+ # @!group Render environment
+
+ # Returns a render environment for the view and the given options. This
+ # environment isn't chdir'ed into any particular directory.
+ #
+ # @param format [Symbol] template format to use (defaults to the `default_format` setting)
+ # @param context [Context] context object to use (defaults to the `default_context` setting)
+ #
+ # @see View.template_env render environment for the view's template
+ # @see View.layout_env render environment for the view's layout
+ #
+ # @return [RenderEnvironment]
+ # @api public
+ def self.render_env(format: config.default_format, context: config.default_context)
+ RenderEnvironment.prepare(renderer(format), config, context)
+ end
+
+ # @overload template_env(format: config.default_format, context: config.default_context)
+ # Returns a render environment for the view and the given options,
+ # chdir'ed into the view's template directory. This is the environment
+ # used when rendering the template, and is useful to to fetch
+ # independently when unit testing Parts and Scopes.
+ #
+ # @param format [Symbol] template format to use (defaults to the `default_format` setting)
+ # @param context [Context] context object to use (defaults to the `default_context` setting)
+ #
+ # @return [RenderEnvironment]
+ # @api public
+ def self.template_env(**args)
+ render_env(**args).chdir(config.template)
+ end
+
+ # @overload layout_env(format: config.default_format, context: config.default_context)
+ # Returns a render environment for the view and the given options,
+ # chdir'ed into the view's layout directory. This is the environment used
+ # when rendering the view's layout.
+ #
+ # @param format [Symbol] template format to use (defaults to the `default_format` setting)
+ # @param context [Context] context object to use (defaults to the `default_context` setting)
+ #
+ # @return [RenderEnvironment] @api public
+ def self.layout_env(**args)
+ render_env(**args).chdir(layout_path)
+ end
+
+ # Returns renderer for the view and provided format
+ #
+ # @api private
+ def self.renderer(format)
+ fetch_or_store(:renderer, config, format) {
+ Renderer.new(
+ config.paths,
+ format: format,
+ engine_mapping: config.renderer_engine_mapping,
+ **config.renderer_options
+ )
+ }
+ end
+
+ # @api private
+ def self.layout_path
+ File.join(*[config.layouts_dir, config.layout].compact)
+ end
+
+ # @!endgroup
+
+ # Returns an instance of the view. This binds the defined exposures to the
+ # view instance.
+ #
+ # Subclasses can define their own `#initialize` to accept injected
+ # dependencies, but must call `super()` to ensure the standard view
+ # initialization can proceed.
+ #
+ # @api public
+ def initialize
+ @exposures = self.class.exposures.bind(self)
+ end
+
+ # The view's configuration
+ #
+ # @api private
+ def config
+ self.class.config
+ end
+
+ # The view's bound exposures
+ #
+ # @return [Exposures]
+ # @api private
+ def exposures
+ @exposures
+ end
+
+ # Render the view
+ #
+ # @param format [Symbol] template format to use
+ # @param context [Context] context object to use
+ # @param input input data for preparing exposure values
+ #
+ # @return [Rendered] rendered view object
+ # @api public
+ def call(format: config.default_format, context: config.default_context, **input)
+ ensure_config
+
+ env = self.class.render_env(format: format, context: context)
+ template_env = self.class.template_env(format: format, context: context)
+
+ locals = locals(template_env, input)
+ output = env.template(config.template, template_env.scope(config.scope, locals))
+
+ if layout?
+ layout_env = self.class.layout_env(format: format, context: context)
+ begin
+ output = env.template(
+ self.class.layout_path,
+ layout_env.scope(config.scope, layout_locals(locals))
+ ) { output }
+ rescue TemplateNotFoundError
+ raise LayoutNotFoundError.new(config.layout, config.paths)
+ end
+ end
+
+ Rendered.new(output: output, locals: locals)
+ end
+
+ private
+
+ # @api private
+ def ensure_config
+ raise UndefinedConfigError, :paths unless Array(config.paths).any?
+ raise UndefinedConfigError, :template unless config.template
+ end
+
+ # @api private
+ def locals(render_env, input)
+ exposures.(context: render_env.context, **input) do |value, exposure|
+ if exposure.decorate? && value
+ render_env.part(exposure.name, value, **exposure.options)
+ else
+ value
+ end
+ end
+ end
+
+ # @api private
+ def layout_locals(locals)
+ locals.each_with_object({}) do |(key, value), layout_locals|
+ layout_locals[key] = value if exposures[key].for_layout?
+ end
+ end
+
+ # @api private
+ def layout?
+ !!config.layout # rubocop:disable Style/DoubleNegation
+ end
end
end