diff --git a/README.md b/README.md index 31484230..e0b1263b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # Hanami::View -A view layer for [Hanami](http://hanamirb.org) +Hanami::View is a complete view rendering system that gives you everything you need to write well-factored view code. + +It can be used as a standalone library or as part of the complete Hanami framework. + +This README has documentation for use as a standalone library, please see the [Hanami guides Views](https://guides.hanamirb.org/views/overview/) section for assistance using it with the full Hanami framework. ## Status @@ -42,10 +46,1197 @@ Or install it yourself as: $ gem install hanami-view ``` +## Usage + +#### Table of contents + +- [Introduction](#introduction) +- [Configuration](#configuration) +- [Injecting dependencies](#injecting-dependencies) +- [Exposures](#exposures) +- [Templates](#templates-1) +- [Parts](#parts) +- [Scopes](#scopes) +- [Context](#context) +- [Testing](#testing) + + +### Introduction + +Use hanami-view if: + +- You recognize that view code can be complex, and want to work with a system that allows you to break your view logic apart into sensible units +- You want to be able to [unit test](#testing) all aspects of your views, in complete isolation +- You want to maintain a sensible separation of concerns between the layers of functionality within your app +- You want to build and render views in any kind of context, not just when serving HTTP requests +- You're using a lightweight routing DSL like Hanami::Router, Roda, or Sinatra and you want to keep your routes clean and easy to understand (hanami-view handles the integration with your application, so all you need to provide from routes is the user-provided input params) +- Your application structure supports dependency injection as the preferred way to share behaviour between components (e.g. hanami-view fits perfectly with [dry-system](https://dry-rb.org/gems/dry-system), [dry-container](https://dry-rb.org/gems/dry-container), and [dry-auto_inject](https://dry-rb.org/gems/dry-auto_inject)) + +#### Concepts + +hanami-view divides the responsibility of view rendering across several different components: + +- The **View**, representing a view in its entirety, holding its configuration as well as any application-provided dependencies +- [**Exposures**](#exposures), defined as part of the view, declare the values that should be exposed to the template, and how they should be prepared +- [**Templates** and **partials**](#templates-1), which contain the markup, code, and logic that determine the view's output. Templates may have different **formats**, which act as differing representations of a given view +- [**Parts**](#parts), which wrap the values exposed to the template and provide a place to encapsulate view-specific behavior along with particular values +- [**Scopes**](#scopes), which offer a place to encapsulate view-specific behaviour intended for a particular _template_ and its complete set of values +- [**Context**](#context), a single object providing the baseline environment for a given rendering, with its methods made available to all templates, partials, parts, and scopes + +#### Example + +[Configure](#configuration) your view, accept some [dependencies](#injecting-dependencies), and define an [exposure](#exposures): + +```ruby +require "hanami/view" + +class ArticleView < Hanami::View + config.paths = [File.join(__dir__, "templates")] + config.part_namespace = Parts + config.layout = "application" + config.template = "articles/show" + + attr_reader :article_repo + + def initialize(article_repo:) + @article_repo = article_repo + end + + expose :article do |slug:| + article_repo.by_slug(slug) + end +end +``` + +Write a layout (`templates/layouts/application.html.erb`): + +```erb + +
+ <%= yield %> + + +``` + +And a [template](#templates-1) (`templates/articles/show.html.erb`): + +```erb +<%= article.byline_text %>
+``` + +Define a [part](#parts) to provide view-specific behavior around the exposed `article` value: + +```ruby +module Parts + class Article < Hanami::View::Part + def byline_text + authors.map(&:name).join(", ") + end + end +end +``` + +Then `#call` your view to render the output: + +```ruby +view = ArticleView.new +view.call(slug: "cheeseburger-backpack").to_s +# => "Rebecca Sugar, Ian Jones-Quartey
+``` + +`Hanami::View#call` expects keyword arguments for input data. These arguments are handled by your [exposures](#exposures), which prepare [view parts](#parts) that are passed to your [template](#templates-1) for rendering. + + +### Configuration +###### ⬆️ Go to [Table of contents](#table-of-contents) + +You can configure your views via class-level `config`. Basic configuration looks like this: + +```ruby +class MyView < Hanami::View + config.paths = [File.join(__dir__, "templates")] + config.layout = "application" + config.template = "my_view" +end +``` + +#### Settings + +##### Templates + +- **paths** (_required_): An array of directories that will be searched for all templates (templates, partials, and layouts). +- **template** (_required_): Name of the template for rendering this view. Template name should be relative to your configured view paths. +- **layout**: Name of the layout to render templates within. Layouts are found within the `layouts_dir` within your configured view paths. A false or nil value will use no layout. Defaults to `nil`. +- **layouts_dir**: Name of the directory to search for layouts (within the configured view paths). Defaults to `"layouts"` +- **default_format**: The format used when looking up template files (templates are found using a `<%= user.name %>
+<% end %> +``` + +#### Partials + +The template scope provides a `#render` method, for rendering partials: + +```erb +<%= render :sidebar %> +``` + +##### Partial lookup + +The template for a partial is prefixed by an underscore and searched through a series of directories, including a directory named after the current template, as well as a "shared" directory. + +So for a `sidebar` partial, rendered within a `users/index.html.erb` template, the partial would be searched for at the following locations in your view's configured paths: + +- `/users/index/_sidebar.html.erb` +- `/users/_sidebar.html.erb` +- `/users/shared/_sidebar.html.erb` + +If a matching partial template is not found in these locations, the search is repeated in each parent directory until the view path’s root is reached, e.g.: + +- `/_sidebar.html.erb` +- `/shared/_sidebar.html.erb` + +##### Partial scope + +A partial called with no arguments is rendered with the same scope as its parent template. This is useful for breaking larger templates up into smaller chunks for readability. For example: + +```erb +<%= user.name %>
+``` + +Or when defining a custom part class: + +```ruby +class User < Hanami::View::Part + def display_name + # `name` and `email` are methods on the decorated user value + "#{name} <#{email}>" + end +end +``` + +In case of naming collisions or when overriding a method, you can access the value directly via `#_value` (or `#value` as a convenience, as long the decorated value itself doesn't respond to `#value`): + +```ruby +class User < Hanami::View::Part + def name + value.name.upcase + end +end +``` + +#### String conversion + +When used to output to the template, a part will use it's value `#to_s` behavior (which you can override in your part classes): + +```erb +<%= user %>
+``` + +#### Rendering partials + +From a part, you can render a partial, with the part object included in the partial's own locals: + +```ruby +class User < Hanami::View::Part + def info_box + render(:info_box) + end +end +``` + +This will render an `_info_box` partial template (via the standard [partial lookup rules](#partial-lookup) with the part still available as `user`. + +You can also render such partials directly within templates: + +```erb +<%= user.render(:info_box) %> +``` + +To make the part available by another name within the partial's cope, use the `as:` option: + +```erb +<%= user.render(:info_box, as: :account) %> +``` + +You can also provide additional locals for the partial: + +```erb +<%= user.render(:info_box, as: :account, title_label: "Your account") %> +``` + +#### Building scopes + +You may [build custom scopes](#scopes) from within a part using `#_scope` (or `#scope` as a convenience, as long as the decorated value doesn't respond to `#scope`): + +```ruby +class User < Hanami::View::Part + def info_box + scope(:info_box, size: :large).render + end +end +``` + +#### Accessing the context + +In your part classes, you can access the [context object](#context) as `#_context` (or `#context` as a convenience, as long the decorated value itself doesn't respond to `#context`). Parts also delegate missing methods to the context object (provided the decorated value itself doesn't respond to the method). + +For example: + +```ruby +class User < Hanami::View::Part + def avatar_url + # asset_path is a method defined on the context object (in this case, + # providing static asset URLs) + value.avatar_url || asset_path("default-user-avatar.png") + end +end +``` + +#### Decorating part attributes + +Your values may have their own attributes that you also want decorated as view parts. Declare these using `decorate` in your own view part classes: + +```ruby +class UserPart < Hanami::View::Part + decorate :address +end +``` + +You can pass the same options to `decorate` as you do to [exposures](#exposures), for example: + +```ruby +class UserPart < Hanami::View::Part + decorate :address, as: :location +end +``` + +#### Memoizing methods + +A part object lives for the entirety of a view rendering, you can memoize expensive operations to ensure they only run once. + +```ruby +class User < Hanami::View::Part + def bio_html + @bio_html ||= rich_text_renderer.render(bio) + end + + private + + def rich_text_renderer + @rich_text_renderer ||= MyRenderer.new + end +end +``` + +#### Custom part class resolution + +When defining your exposures, use the `as:` option to specify an alternative name or class for part decoration. + +For singular values: + +- `expose :article, as: :story` will look up a `Parts::Story` class +- `expose :article, as: Parts::MyArticle` will use the provided class + +For arrays: + +- `expose :articles, as: :stories` will look up `Parts::Stories` for decorating the array, and `Parts::Story` for decorating the elements +- `expose :articles, as: [:story_collection]` will look up `Parts::StoryCollection` for decorating the array, and `Parts::Article` for decorating the elements +- `expose :articles, as: [:story_collection, :story]` will look up `Parts::StoryCollection` for decorating the array, and `Parts::Story` for decorating the elements +- For the two `as:` structures above (with the names in the array), explicit classes can be provided instead of symbols, and they'll be used for decorating their respective items + +All of these examples presume a configured `part_namespace` of `Parts`. + +#### Providing a custom part builder + +To fully customize part decoration, you can provide a replacement part builder: + +```ruby +class MyView < Hanami::View + config.part_builder = MyPartBuilder +end +``` + +Your part builder must conform to the following interface: + +- `#initialize(namespace: nil, render_env: nil)` +- `#for_render_env(render_env)` +- `#call(name, value, **options)` + +You can also inherit from `Hanami::View::PartBuilder` and override any of its methods, if you want to customize just a particular aspect of the standard behavior. + + +### Scopes +###### ⬆️ Go to [Table of contents](#table-of-contents) + +All values [exposed](#exposures) by your view are decorated and passed to your templates as _parts_, which allow encapsulation of view-specific behavior alongside your application's domain objects. + +Unlike many third-party approaches to view object decoration, hanami-view's parts are fully integrated and have access to the full rendering environment, which means that anything you can do from a template, you can also do from a part. This includes accessing the context object as well as rendering partials and building scopes. + +This means that much more view logic can move out of template and into parts, which makes the templates simpler and more declarative, and puts the view logic into a place where it can be reused and refactored using typical object oriented approaches, as well as tested in isolation. + +#### Defining a part class + +To provide custom part behavior, define your own part classes in a common namespace (e.g. `Parts`) and [configure that](#configuration) as your view's `part_namespace` Each part class must inherit from `Hanami::View::Part`. + +```ruby +module Parts + class User < Hanami::View::Part + end +end +``` + +#### Part class resolution + +Part classes are looked up based on each exposure's name. + +So for an exposure named `:article`, the `Parts::Article` class will be looked up and used to decorate the article value. + +For an exposure returning an array, the exposure's name will be singularized and each element in the array will be decorated with a matching part. Then the array _itself_ will be decorated by a matching part. + +So for an exposure named `:articles`, the `Parts::Article` class will be looked up for decorating each element, and the `Parts::Articles` class will be looked up for decorating the entire array. + +If a matching part class cannot be found, the standard `Hanami::View::Part` class will be used. + +If your application does not use class autoloading, you should explicitly `require` your part files to ensure the classes are available. + +#### Accessing the decorated value + +When using a part within a template, or when defining your own part methods, you can call the decorated value's methods and the part object will pass them through (via `#method_missing`). + +For example, from a template: + +```erb + +<%= user.name %>
+``` + +Or when defining a custom part class: + +```ruby +class User < Hanami::View::Part + def display_name + # `name` and `email` are methods on the decorated user value + "#{name} <#{email}>" + end +end +``` + +In case of naming collisions or when overriding a method, you can access the value directly via `#_value` (or `#value` as a convenience, as long the decorated value itself doesn't respond to `#value`): + +```ruby +class User < Hanami::View::Part + def name + value.name.upcase + end +end +``` + +#### String conversion + +When used to output to the template, a part will use it's value `#to_s` behavior (which you can override in your part classes): + +```erb +<%= user %>
+``` + +#### Rendering partials + +From a part, you can render a partial, with the part object included in the partial's own locals: + +```ruby +class User < Hanami::View::Part + def info_box + render(:info_box) + end +end +``` + +This will render an `_info_box` partial template (via the standard [partial lookup rules](#templates-1)) with the part still available as `user`. + +You can also render such partials directly within templates: + +```erb +<%= user.render(:info_box) %> +``` + +To make the part available by another name within the partial's cope, use the `as:` option: + +```erb +<%= user.render(:info_box, as: :account) %> +``` + +You can also provide additional locals for the partial: + +```erb +<%= user.render(:info_box, as: :account, title_label: "Your account") %> +``` + +#### Building scopes + +You may [build custom scopes](#scopes) from within a part using `#_scope` (or `#scope` as a convenience, as long as the decorated value doesn't respond to `#scope`): + +```ruby +class User < Hanami::View::Part + def info_box + scope(:info_box, size: :large).render + end +end +``` + +#### Accessing the context + +In your part classes, you can access the [context object](#context) as `#_context` (or `#context` as a convenience, as long the decorated value itself doesn't respond to `#context`). Parts also delegate missing methods to the context object (provided the decorated value itself doesn't respond to the method). + +For example: + +```ruby +class User < Hanami::View::Part + def avatar_url + # asset_path is a method defined on the context object (in this case, + # providing static asset URLs) + value.avatar_url || asset_path("default-user-avatar.png") + end +end +``` + +#### Decorating part attributes + +Your values may have their own attributes that you also want decorated as view parts. Declare these using `decorate` in your own view part classes: + +```ruby +class UserPart < Hanami::View::Part + decorate :address +end +``` + +You can pass the same options to `decorate` as you do to [exposures](#exposures), for example: + +```ruby +class UserPart < Hanami::View::Part + decorate :address, as: :location +end +``` + +#### Memoizing methods + +A part object lives for the entirety of a view rendering, you can memoize expensive operations to ensure they only run once. + +```ruby +class User < Hanami::View::Part + def bio_html + @bio_html ||= rich_text_renderer.render(bio) + end + + private + + def rich_text_renderer + @rich_text_renderer ||= MyRenderer.new + end +end +``` + +#### Custom part class resolution + +When defining your exposures, use the `as:` option to specify an alternative name or class for part decoration. + +For singular values: + +- `expose :article, as: :story` will look up a `Parts::Story` class +- `expose :article, as: Parts::MyArticle` will use the provided class + +For arrays: + +- `expose :articles, as: :stories` will look up `Parts::Stories` for decorating the array, and `Parts::Story` for decorating the elements +- `expose :articles, as: [:story_collection]` will look up `Parts::StoryCollection` for decorating the array, and `Parts::Article` for decorating the elements +- `expose :articles, as: [:story_collection, :story]` will look up `Parts::StoryCollection` for decorating the array, and `Parts::Story` for decorating the elements +- For the two `as:` structures above (with the names in the array), explicit classes can be provided instead of symbols, and they'll be used for decorating their respective items + +All of these examples presume a configured `part_namespace` of `Parts`. + +#### Providing a custom part builder + +To fully customize part decoration, you can provide a replacement part builder: + +```ruby +class MyView < Hanami::View + config.part_builder = MyPartBuilder +end +``` + +Your part builder must conform to the following interface: + +- `#initialize(namespace: nil, render_env: nil)` +- `#for_render_env(render_env)` +- `#call(name, value, **options)` + +You can also inherit from `Hanami::View::PartBuilder` and override any of its methods, if you want to customize just a particular aspect of the standard behavior. + + +### Context +###### ⬆️ Go to [Table of contents](#table-of-contents) + +Use a context object to provide shared facilities to every template, partial, scope, and part in a given view rendering. + +A context object is helpful in holding any behaviour or data you don't want to pass around explicitly. For example: + +- Data specific to the current HTTP request, like the request path and CSRF tags +- A "current user" or similar session-based object needed across multiple disparate places +- Application static assets helpers +- `content_for`-style helpers + +#### Defining a context + +Context classes must inherit from `Hanami::View::Context` + +```ruby +class MyContext < Hanami::View::Context +end +``` + +#### Injecting dependencies + +`Hanami::View::Context` is designed to allow dependencies to be injected into your subclasses. To do this, accept your dependencies as keyword arguments to `#initialize`, and pass all arguments through to `super`: + +```ruby +class MyContext < Hanami::View::Context + attr_reader :assets + + def initialize(assets:, **args) + @assets = assets + super + end + + def asset_path(asset_name) + assets[asset_name] + end +end +``` + +If your app uses [dry-system](https://dry-rb.org/gems/dry-system) or [dry-auto_inject](https://dry-rb.org/gems/dry-auto_inject), this is even less work. dry-auto_inject works out of the box with `Hanami::View::Context`’s initializer: + +```ruby +# Require the auto-injector module for your app's container +require "my_app/import" + +class MyContext < Hanami::View::Context + include MyApp::Import["assets"] + + def asset_path(asset_name) + assets[asset_name] + end +end +``` + +#### Providing the context + +The default context can be `configured` for a view: + +```ruby +class MyView < Hanami::View + config.default_context = MyContext.new +end +``` + +Or provided at render-time, when calling a view: + +```ruby +my_view.call(context: my_context) +``` + +This context object will override whatever has been previously configured. + +When providing a context at render time, you may wish to provide a version of your context object with e.g. data specific to the current HTTP request, which is not available when configuring the view with a context. + +#### Decorating context attributes + +Your context may have attribute that you want decorated as [parts](#parts). Declare these using `decorate` in your context class: + +```ruby +class MyContext < Hanami::View::Context + decorate :navigation_items + + attr_reader :navigation_items + + def initialize(navigation_items:, **args) + @navigation_items = navigation_items + super(**args) + end +end +``` + +You can pass the same options to `decorate` as you do to [exposures](#exposures), for example: + +```ruby +class MyContext < Hanami::View::Context + decorate :navigation_items, as: :menu_items + + # ... +end +``` + + +### Context +###### ⬆️ Go to [Table of contents](#table-of-contents) + +Use a context object to provide shared facilities to every template, partial, scope, and part in a given view rendering. + +A context object is helpful in holding any behaviour or data you don't want to pass around explicitly. For example: + +- Data specific to the current HTTP request, like the request path and CSRF tags +- A "current user" or similar session-based object needed across multiple disparate places +- Application static assets helpers +- `content_for`-style helpers + +#### Defining a context + +Context classes must inherit from `Hanami::View::Context` + +```ruby +class MyContext < Hanami::View::Context +end +``` + +#### Injecting dependencies + +`Hanami::View::Context` is designed to allow dependencies to be injected into your subclasses. To do this, accept your dependencies as keyword arguments to `#initialize`, and pass all arguments through to `super`: + +```ruby +class MyContext < Hanami::View::Context + attr_reader :assets + + def initialize(assets:, **args) + @assets = assets + super + end + + def asset_path(asset_name) + assets[asset_name] + end +end +``` + +If your app uses [dry-system](https://dry-rb.org/gems/dry-system) or [dry-auto_inject](https://dry-rb.org/gems/dry-auto_inject), this is even less work. dry-auto_inject works out of the box with `Hanami::View::Context`’s initializer: + +```ruby +# Require the auto-injector module for your app's container +require "my_app/import" + +class MyContext < Hanami::View::Context + include MyApp::Import["assets"] + + def asset_path(asset_name) + assets[asset_name] + end +end +``` + +#### Providing the context + +The default context can be `configured` for a view: + +```ruby +class MyView < Hanami::View + config.default_context = MyContext.new +end +``` + +Or provided at render-time, when calling a view: + +```ruby +my_view.call(context: my_context) +``` + +This context object will override whatever has been previously configured. + +When providing a context at render time, you may wish to provide a version of your context object with e.g. data specific to the current HTTP request, which is not available when configuring the view with a context. + +#### Decorating context attributes + +Your context may have attribute that you want decorated as [parts](#parts). Declare these using `decorate` in your context class: + +```ruby +class MyContext < Hanami::View::Context + decorate :navigation_items + + attr_reader :navigation_items + + def initialize(navigation_items:, **args) + @navigation_items = navigation_items + super(**args) + end +end +``` + +You can pass the same options to `decorate` as you do to [exposures](#exposures), for example: + +```ruby +class MyContext < Hanami::View::Context + decorate :navigation_items, as: :menu_items + + # ... +end +``` + +### Testing +###### ⬆️ Go to [Table of contents](#table-of-contents) + +Use a context object to provide shared facilities to every template, partial, scope, and part in a given view rendering. + +A context object is helpful in holding any behaviour or data you don't want to pass around explicitly. For example: + +- Data specific to the current HTTP request, like the request path and CSRF tags +- A "current user" or similar session-based object needed across multiple disparate places +- Application static assets helpers +- `content_for`-style helpers + +#### Defining a context + +Context classes must inherit from `Hanami::View::Context` + +```ruby +class MyContext < Hanami::View::Context +end +``` + +#### Injecting dependencies + +`Hanami::View::Context` is designed to allow dependencies to be injected into your subclasses. To do this, accept your dependencies as keyword arguments to `#initialize`, and pass all arguments through to `super`: + +```ruby +class MyContext < Hanami::View::Context + attr_reader :assets + + def initialize(assets:, **args) + @assets = assets + super + end + + def asset_path(asset_name) + assets[asset_name] + end +end +``` + +If your app uses [dry-system](https://dry-rb.org/gems/dry-system) or [dry-auto_inject](https://dry-rb.org/gems/dry-auto_inject), this is even less work. dry-auto_inject works out of the box with `Hanami::View::Context`’s initializer: + +```ruby +# Require the auto-injector module for your app's container +require "my_app/import" + +class MyContext < Hanami::View::Context + include MyApp::Import["assets"] + + def asset_path(asset_name) + assets[asset_name] + end +end +``` + +#### Providing the context + +The default context can be `configured` for a view: + +```ruby +class MyView < Hanami::View + config.default_context = MyContext.new +end +``` + +Or provided at render-time, when calling a view: + +```ruby +my_view.call(context: my_context) +``` + +This context object will override whatever has been previously configured. + +When providing a context at render time, you may wish to provide a version of your context object with e.g. data specific to the current HTTP request, which is not available when configuring the view with a context. + +#### Decorating context attributes + +Your context may have attribute that you want decorated as [parts](#parts). Declare these using `decorate` in your context class: + +```ruby +class MyContext < Hanami::View::Context + decorate :navigation_items + + attr_reader :navigation_items + + def initialize(navigation_items:, **args) + @navigation_items = navigation_items + super(**args) + end +end +``` + +You can pass the same options to `decorate` as you do to [exposures](#exposures), for example: + +```ruby +class MyContext < Hanami::View::Context + decorate :navigation_items, as: :menu_items + + # ... +end +``` + ## Versioning __Hanami::View__ uses [Semantic Versioning 2.0.0](http://semver.org) + ## Contributing 1. Fork it