diff --git a/Gemfile b/Gemfile index db919e4..37a5428 100644 --- a/Gemfile +++ b/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 diff --git a/Gemfile.devtools b/Gemfile.devtools index f759039..2ffdb94 100644 --- a/Gemfile.devtools +++ b/Gemfile.devtools @@ -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 diff --git a/lib/hanami/view.rb b/lib/hanami/view.rb index 0afca25..c831144 100644 --- a/lib/hanami/view.rb +++ b/lib/hanami/view.rb @@ -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 diff --git a/lib/hanami/view/application_view.rb b/lib/hanami/view/application_view.rb new file mode 100644 index 0000000..5ecb812 --- /dev/null +++ b/lib/hanami/view/application_view.rb @@ -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 diff --git a/lib/hanami/view/standalone_view.rb b/lib/hanami/view/standalone_view.rb new file mode 100644 index 0000000..cd807b9 --- /dev/null +++ b/lib/hanami/view/standalone_view.rb @@ -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 diff --git a/spec/integration/application_view_spec.rb b/spec/integration/application_view_spec.rb new file mode 100644 index 0000000..1bd6cbf --- /dev/null +++ b/spec/integration/application_view_spec.rb @@ -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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d6f431a..25fa646 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -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" diff --git a/spec/support/application_integration.rb b/spec/support/application_integration.rb new file mode 100644 index 0000000..7d30200 --- /dev/null +++ b/spec/support/application_integration.rb @@ -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