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