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"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
|
||||||
|
|
||||||
eval_gemfile "Gemfile.devtools"
|
eval_gemfile "Gemfile.devtools"
|
||||||
|
|
||||||
gemspec
|
gemspec
|
||||||
|
@ -12,12 +14,14 @@ group :tools do
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
gem "rack", ">= 2.0.6"
|
gem "dry-inflector"
|
||||||
|
|
||||||
gem "erbse", "~> 0.1.4"
|
gem "erbse", "~> 0.1.4"
|
||||||
gem "erubi"
|
gem "erubi"
|
||||||
gem "hamlit"
|
gem "hamlit"
|
||||||
gem "hamlit-block"
|
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"
|
gem "slim", "~> 4.0"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,3 @@ group :test do
|
||||||
gem "warning"
|
gem "warning"
|
||||||
end
|
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/equalizer"
|
||||||
require "dry/inflector"
|
require "dry/inflector"
|
||||||
|
|
||||||
|
require_relative "view/application_view"
|
||||||
require_relative "view/context"
|
require_relative "view/context"
|
||||||
require_relative "view/exposures"
|
require_relative "view/exposures"
|
||||||
require_relative "view/errors"
|
require_relative "view/errors"
|
||||||
|
@ -14,6 +15,7 @@ require_relative "view/render_environment"
|
||||||
require_relative "view/rendered"
|
require_relative "view/rendered"
|
||||||
require_relative "view/renderer"
|
require_relative "view/renderer"
|
||||||
require_relative "view/scope_builder"
|
require_relative "view/scope_builder"
|
||||||
|
require_relative "view/standalone_view"
|
||||||
|
|
||||||
module Hanami
|
module Hanami
|
||||||
# A standalone, template-based view rendering system that offers everything
|
# A standalone, template-based view rendering system that offers everything
|
||||||
|
@ -213,298 +215,23 @@ module Hanami
|
||||||
|
|
||||||
# @!endgroup
|
# @!endgroup
|
||||||
|
|
||||||
# @api private
|
include StandaloneView
|
||||||
def self.inherited(klass)
|
|
||||||
|
def self.inherited(subclass)
|
||||||
super
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
# @!group Exposures
|
def self.application_provider(subclass)
|
||||||
|
if Hanami.respond_to?(:application?) && Hanami.application?
|
||||||
# @!macro [new] exposure_options
|
Hanami.application.component_provider(subclass)
|
||||||
# @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
|
||||||
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 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
|
|
||||||
end
|
end
|
||||||
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 "slim"
|
||||||
require "hanami/view"
|
require "hanami/view"
|
||||||
|
|
||||||
module Test
|
module TestNamespace
|
||||||
def self.remove_constants
|
def remove_constants
|
||||||
constants.each(&method(:remove_const))
|
constants.each(&method(:remove_const))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
module Test
|
||||||
|
extend TestNamespace
|
||||||
|
end
|
||||||
|
|
||||||
RSpec.configure do |config|
|
RSpec.configure do |config|
|
||||||
config.disable_monkey_patching!
|
config.disable_monkey_patching!
|
||||||
|
|
||||||
|
@ -48,3 +52,5 @@ RSpec::Matchers.define :part_including do |data|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
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