Integrate seamlessly with Hanami applications (#173)
This commit is contained in:
parent
c988b2dc2b
commit
622c0dd3e7
8
Gemfile
8
Gemfile
|
@ -2,6 +2,8 @@
|
|||
|
||||
source "https://rubygems.org"
|
||||
|
||||
git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
|
||||
|
||||
eval_gemfile "Gemfile.devtools"
|
||||
|
||||
gemspec
|
||||
|
@ -12,12 +14,14 @@ group :tools do
|
|||
end
|
||||
|
||||
group :test do
|
||||
gem "rack", ">= 2.0.6"
|
||||
|
||||
gem "dry-inflector"
|
||||
gem "erbse", "~> 0.1.4"
|
||||
gem "erubi"
|
||||
gem "hamlit"
|
||||
gem "hamlit-block"
|
||||
gem "hanami", github: "hanami/hanami", branch: "unstable"
|
||||
gem "hanami-devtools", github: "hanami/devtools"
|
||||
gem "rack", ">= 2.0.6"
|
||||
gem "slim", "~> 4.0"
|
||||
end
|
||||
|
||||
|
|
|
@ -13,7 +13,3 @@ group :test do
|
|||
gem "warning"
|
||||
end
|
||||
|
||||
group :tools do
|
||||
# this is the same version that we use on codacy
|
||||
gem "rubocop", "0.71.0"
|
||||
end
|
||||
|
|
|
@ -5,6 +5,7 @@ require "dry/core/cache"
|
|||
require "dry/equalizer"
|
||||
require "dry/inflector"
|
||||
|
||||
require_relative "view/application_view"
|
||||
require_relative "view/context"
|
||||
require_relative "view/exposures"
|
||||
require_relative "view/errors"
|
||||
|
@ -14,6 +15,7 @@ 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
|
||||
|
@ -213,298 +215,23 @@ module Hanami
|
|||
|
||||
# @!endgroup
|
||||
|
||||
# @api private
|
||||
def self.inherited(klass)
|
||||
include StandaloneView
|
||||
|
||||
def self.inherited(subclass)
|
||||
super
|
||||
exposures.each do |name, exposure|
|
||||
klass.exposures.import(name, exposure)
|
||||
|
||||
# If inheriting directly from Hanami::View within an Hanami app, configure
|
||||
# the view for the application
|
||||
if subclass.superclass == View && (provider = application_provider(subclass))
|
||||
subclass.include ApplicationView.new(provider)
|
||||
end
|
||||
end
|
||||
|
||||
# @!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
|
||||
def self.application_provider(subclass)
|
||||
if Hanami.respond_to?(:application?) && Hanami.application?
|
||||
Hanami.application.component_provider(subclass)
|
||||
end
|
||||
end
|
||||
|
||||
# @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 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
|
||||
|
||||
# The view's bound exposures
|
||||
#
|
||||
# @return [Exposures]
|
||||
# @api private
|
||||
attr_reader :exposures
|
||||
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
output = env.template(
|
||||
self.class.layout_path,
|
||||
layout_env.scope(config.scope, layout_locals(locals))
|
||||
) { output }
|
||||
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
|
||||
private_class_method :application_provider
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
module Hanami
|
||||
class View
|
||||
class ApplicationView < Module
|
||||
InheritedHook = Class.new(Module)
|
||||
|
||||
attr_reader :provider
|
||||
attr_reader :application
|
||||
attr_reader :inherited_hook
|
||||
|
||||
def initialize(provider)
|
||||
@provider = provider
|
||||
@application = provider.respond_to?(:application) ? provider.application : Hanami.application
|
||||
@inherited_hook = InheritedHook.new
|
||||
|
||||
define_inherited_hook
|
||||
end
|
||||
|
||||
def included(view_class)
|
||||
view_class.config.paths = [provider.root.join(application.config.views.templates_path).to_s]
|
||||
view_class.config.layouts_dir = application.config.views.layouts_dir
|
||||
view_class.config.layout = application.config.views.default_layout
|
||||
|
||||
view_class.extend inherited_hook
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def define_inherited_hook
|
||||
template_name = method(:template_name)
|
||||
|
||||
inherited_hook.send :define_method, :inherited do |subclass|
|
||||
super(subclass)
|
||||
subclass.config.template = template_name.(subclass)
|
||||
end
|
||||
end
|
||||
|
||||
def template_name(view_class)
|
||||
provider
|
||||
.inflector
|
||||
.underscore(view_class.name)
|
||||
.sub(/^#{provider.namespace_path}\//, "")
|
||||
.sub(/^#{application.config.views.base_path}\//, "")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,311 @@
|
|||
module Hanami
|
||||
class View
|
||||
module StandaloneView
|
||||
def self.included(klass)
|
||||
klass.extend ClassMethods
|
||||
klass.include InstanceMethods
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
# @api private
|
||||
def inherited(klass)
|
||||
super
|
||||
|
||||
exposures.each do |name, exposure|
|
||||
klass.exposures.import(name, exposure)
|
||||
end
|
||||
end
|
||||
|
||||
# @!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 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
|
||||
|
||||
# @api public
|
||||
def 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 exposures
|
||||
@exposures ||= Exposures.new
|
||||
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 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 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 layout_env(**args)
|
||||
render_env(**args).chdir(layout_path)
|
||||
end
|
||||
|
||||
# Returns renderer for the view and provided format
|
||||
#
|
||||
# @api private
|
||||
def 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 layout_path
|
||||
File.join(*[config.layouts_dir, config.layout].compact)
|
||||
end
|
||||
|
||||
# @!endgroup
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
# 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)
|
||||
output = env.template(
|
||||
self.class.layout_path,
|
||||
layout_env.scope(config.scope, layout_locals(locals))
|
||||
) { output }
|
||||
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
|
||||
end
|
||||
end
|
|
@ -0,0 +1,172 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "hanami"
|
||||
require "hanami/view"
|
||||
|
||||
RSpec.describe "Application views" do
|
||||
context "Outside Hanami app" do
|
||||
subject(:view_class) { Class.new(Hanami::View) }
|
||||
|
||||
before do
|
||||
allow(Hanami).to receive(:respond_to?).with(:application?) { nil }
|
||||
end
|
||||
|
||||
it "is not an application view" do
|
||||
expect(view_class.ancestors).not_to include(a_kind_of(Hanami::View::ApplicationView))
|
||||
end
|
||||
|
||||
it "does not configure the view" do
|
||||
expect(view_class.config.paths).to eq []
|
||||
end
|
||||
end
|
||||
|
||||
context "Inside Hanami app", :application_integration do
|
||||
before do
|
||||
module TestApp
|
||||
class Application < Hanami::Application
|
||||
config.root = "/path/to/app"
|
||||
config.views.base_path = "views"
|
||||
config.views.templates_path = "templates"
|
||||
config.views.layouts_dir = "test_app_layouts"
|
||||
config.views.default_layout = "testing"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "Base view defined inside slice" do
|
||||
before do
|
||||
module Main
|
||||
end
|
||||
|
||||
Hanami.application.register_slice :main, namespace: Main, root: "/path/to/app/slices/main"
|
||||
Hanami.init
|
||||
end
|
||||
|
||||
let!(:base_view_class) {
|
||||
module Main
|
||||
class View < Hanami::View
|
||||
end
|
||||
end
|
||||
|
||||
Main::View
|
||||
}
|
||||
|
||||
describe "base view class" do
|
||||
subject(:view_class) { base_view_class }
|
||||
|
||||
it "is an application view" do
|
||||
expect(view_class.ancestors).to include(a_kind_of(Hanami::View::ApplicationView))
|
||||
end
|
||||
|
||||
it "applies configuration from application" do
|
||||
config = view_class.config
|
||||
|
||||
aggregate_failures do
|
||||
expect(config.paths.map { |path| path.dir.to_s }).to eq ["/path/to/app/slices/main/templates"]
|
||||
expect(config.layouts_dir).to eq "test_app_layouts"
|
||||
expect(config.layout).to eq "testing"
|
||||
end
|
||||
end
|
||||
|
||||
it "does not configure the template" do
|
||||
expect(view_class.config.template).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "subclass of base view class" do
|
||||
subject(:view_class) {
|
||||
module Main
|
||||
module Views
|
||||
module Articles
|
||||
class Index < Main::View
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Main::Views::Articles::Index
|
||||
}
|
||||
|
||||
it "inherits the application-specific configuration from the base class" do
|
||||
config = view_class.config
|
||||
|
||||
aggregate_failures do
|
||||
expect(config.paths.map { |path| path.dir.to_s }).to eq ["/path/to/app/slices/main/templates"]
|
||||
expect(config.layouts_dir).to eq "test_app_layouts"
|
||||
expect(config.layout).to eq "testing"
|
||||
end
|
||||
end
|
||||
|
||||
it "configures the template name based on the view's class name, relative to the slice and configured views base_path" do
|
||||
expect(view_class.config.template).to eq "articles/index"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "Base view defined directly inside application" do
|
||||
before do
|
||||
Hanami.init
|
||||
end
|
||||
|
||||
let!(:base_view_class) {
|
||||
module TestApp
|
||||
class View < Hanami::View
|
||||
end
|
||||
end
|
||||
|
||||
TestApp::View
|
||||
}
|
||||
|
||||
describe "base view class" do
|
||||
subject(:view_class) { base_view_class }
|
||||
|
||||
it "is an application view" do
|
||||
expect(view_class.ancestors).to include(a_kind_of(Hanami::View::ApplicationView))
|
||||
end
|
||||
|
||||
it "applies configuration from application" do
|
||||
config = view_class.config
|
||||
|
||||
aggregate_failures do
|
||||
expect(config.paths.map { |path| path.dir.to_s }).to eq ["/path/to/app/templates"]
|
||||
expect(config.layouts_dir).to eq "test_app_layouts"
|
||||
expect(config.layout).to eq "testing"
|
||||
end
|
||||
end
|
||||
|
||||
it "does not configure the template" do
|
||||
expect(view_class.config.template).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "subclass of base view class" do
|
||||
subject(:view_class) {
|
||||
module TestApp
|
||||
module Views
|
||||
module Articles
|
||||
class Index < TestApp::View
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
TestApp::Views::Articles::Index
|
||||
}
|
||||
|
||||
it "inherits the application-specific configuration from the base class" do
|
||||
config = view_class.config
|
||||
|
||||
aggregate_failures do
|
||||
expect(config.paths.map { |path| path.dir.to_s }).to eq ["/path/to/app/templates"]
|
||||
expect(config.layouts_dir).to eq "test_app_layouts"
|
||||
expect(config.layout).to eq "testing"
|
||||
end
|
||||
end
|
||||
|
||||
it "configures the template name based on the view's class name, relative to the slice and configured views base_path" do
|
||||
expect(view_class.config.template).to eq "articles/index"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -11,12 +11,16 @@ FIXTURES_PATH = SPEC_ROOT.join("fixtures")
|
|||
require "slim"
|
||||
require "hanami/view"
|
||||
|
||||
module Test
|
||||
def self.remove_constants
|
||||
module TestNamespace
|
||||
def remove_constants
|
||||
constants.each(&method(:remove_const))
|
||||
end
|
||||
end
|
||||
|
||||
module Test
|
||||
extend TestNamespace
|
||||
end
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.disable_monkey_patching!
|
||||
|
||||
|
@ -48,3 +52,5 @@ RSpec::Matchers.define :part_including do |data|
|
|||
}
|
||||
}
|
||||
end
|
||||
|
||||
require_relative "support/application_integration"
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "hanami/devtools/integration/files"
|
||||
require "hanami/devtools/integration/with_tmp_directory"
|
||||
|
||||
RSpec.shared_context "Application integration" do
|
||||
let(:application_modules) { %i[TestApp Main] }
|
||||
end
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.include RSpec::Support::Files, :application_integration
|
||||
config.include RSpec::Support::WithTmpDirectory, :application_integration
|
||||
config.include_context "Application integration", :application_integration
|
||||
|
||||
config.before :each, :application_integration do
|
||||
@load_paths = $LOAD_PATH.dup
|
||||
|
||||
application_modules.each do |app_module|
|
||||
Object.const_set(app_module, Module.new { |m| m.extend(TestNamespace) })
|
||||
end
|
||||
end
|
||||
|
||||
config.after :each, :application_integration do
|
||||
$LOAD_PATH.replace(@load_paths)
|
||||
$LOADED_FEATURES.delete_if do |feature_path|
|
||||
feature_path =~ %r{hanami/(setup|init|boot)}
|
||||
end
|
||||
|
||||
application_modules.each do |app_module|
|
||||
Object.const_get(app_module).remove_constants
|
||||
Object.send :remove_const, app_module
|
||||
end
|
||||
|
||||
%i[@_application @_app].each do |ivar|
|
||||
Hanami.remove_instance_variable(ivar) if Hanami.instance_variable_defined?(ivar)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue