diff options
Diffstat (limited to 'actionview/lib/action_view/renderer')
6 files changed, 925 insertions, 0 deletions
diff --git a/actionview/lib/action_view/renderer/abstract_renderer.rb b/actionview/lib/action_view/renderer/abstract_renderer.rb new file mode 100644 index 0000000000..20b2523cac --- /dev/null +++ b/actionview/lib/action_view/renderer/abstract_renderer.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module ActionView + # This class defines the interface for a renderer. Each class that + # subclasses +AbstractRenderer+ is used by the base +Renderer+ class to + # render a specific type of object. + # + # The base +Renderer+ class uses its +render+ method to delegate to the + # renderers. These currently consist of + # + # PartialRenderer - Used for rendering partials + # TemplateRenderer - Used for rendering other types of templates + # StreamingTemplateRenderer - Used for streaming + # + # Whenever the +render+ method is called on the base +Renderer+ class, a new + # renderer object of the correct type is created, and the +render+ method on + # that new object is called in turn. This abstracts the setup and rendering + # into a separate classes for partials and templates. + class AbstractRenderer #:nodoc: + delegate :find_template, :find_file, :template_exists?, :any_templates?, :with_fallbacks, :with_layout_format, :formats, to: :@lookup_context + + def initialize(lookup_context) + @lookup_context = lookup_context + end + + def render + raise NotImplementedError + end + + private + + def extract_details(options) # :doc: + @lookup_context.registered_details.each_with_object({}) do |key, details| + value = options[key] + + details[key] = Array(value) if value + end + end + + def instrument(name, **options) # :doc: + options[:identifier] ||= (@template && @template.identifier) || @path + + ActiveSupport::Notifications.instrument("render_#{name}.action_view", options) do |payload| + yield payload + end + end + + def prepend_formats(formats) # :doc: + formats = Array(formats) + return if formats.empty? || @lookup_context.html_fallback_for_js + + @lookup_context.formats = formats | @lookup_context.formats + end + end +end diff --git a/actionview/lib/action_view/renderer/partial_renderer.rb b/actionview/lib/action_view/renderer/partial_renderer.rb new file mode 100644 index 0000000000..f548fc24ed --- /dev/null +++ b/actionview/lib/action_view/renderer/partial_renderer.rb @@ -0,0 +1,552 @@ +# frozen_string_literal: true + +require "concurrent/map" +require_relative "partial_renderer/collection_caching" + +module ActionView + class PartialIteration + # The number of iterations that will be done by the partial. + attr_reader :size + + # The current iteration of the partial. + attr_reader :index + + def initialize(size) + @size = size + @index = 0 + end + + # Check if this is the first iteration of the partial. + def first? + index == 0 + end + + # Check if this is the last iteration of the partial. + def last? + index == size - 1 + end + + def iterate! # :nodoc: + @index += 1 + end + end + + # = Action View Partials + # + # There's also a convenience method for rendering sub templates within the current controller that depends on a + # single object (we call this kind of sub templates for partials). It relies on the fact that partials should + # follow the naming convention of being prefixed with an underscore -- as to separate them from regular + # templates that could be rendered on their own. + # + # In a template for Advertiser#account: + # + # <%= render partial: "account" %> + # + # This would render "advertiser/_account.html.erb". + # + # In another template for Advertiser#buy, we could have: + # + # <%= render partial: "account", locals: { account: @buyer } %> + # + # <% @advertisements.each do |ad| %> + # <%= render partial: "ad", locals: { ad: ad } %> + # <% end %> + # + # This would first render "advertiser/_account.html.erb" with @buyer passed in as the local variable +account+, then + # render "advertiser/_ad.html.erb" and pass the local variable +ad+ to the template for display. + # + # == The :as and :object options + # + # By default <tt>ActionView::PartialRenderer</tt> doesn't have any local variables. + # The <tt>:object</tt> option can be used to pass an object to the partial. For instance: + # + # <%= render partial: "account", object: @buyer %> + # + # would provide the <tt>@buyer</tt> object to the partial, available under the local variable +account+ and is + # equivalent to: + # + # <%= render partial: "account", locals: { account: @buyer } %> + # + # With the <tt>:as</tt> option we can specify a different name for said local variable. For example, if we + # wanted it to be +user+ instead of +account+ we'd do: + # + # <%= render partial: "account", object: @buyer, as: 'user' %> + # + # This is equivalent to + # + # <%= render partial: "account", locals: { user: @buyer } %> + # + # == \Rendering a collection of partials + # + # The example of partial use describes a familiar pattern where a template needs to iterate over an array and + # render a sub template for each of the elements. This pattern has been implemented as a single method that + # accepts an array and renders a partial by the same name as the elements contained within. So the three-lined + # example in "Using partials" can be rewritten with a single line: + # + # <%= render partial: "ad", collection: @advertisements %> + # + # This will render "advertiser/_ad.html.erb" and pass the local variable +ad+ to the template for display. An + # iteration object will automatically be made available to the template with a name of the form + # +partial_name_iteration+. The iteration object has knowledge about which index the current object has in + # the collection and the total size of the collection. The iteration object also has two convenience methods, + # +first?+ and +last?+. In the case of the example above, the template would be fed +ad_iteration+. + # For backwards compatibility the +partial_name_counter+ is still present and is mapped to the iteration's + # +index+ method. + # + # The <tt>:as</tt> option may be used when rendering partials. + # + # You can specify a partial to be rendered between elements via the <tt>:spacer_template</tt> option. + # The following example will render <tt>advertiser/_ad_divider.html.erb</tt> between each ad partial: + # + # <%= render partial: "ad", collection: @advertisements, spacer_template: "ad_divider" %> + # + # If the given <tt>:collection</tt> is +nil+ or empty, <tt>render</tt> will return nil. This will allow you + # to specify a text which will be displayed instead by using this form: + # + # <%= render(partial: "ad", collection: @advertisements) || "There's no ad to be displayed" %> + # + # NOTE: Due to backwards compatibility concerns, the collection can't be one of hashes. Normally you'd also + # just keep domain objects, like Active Records, in there. + # + # == \Rendering shared partials + # + # Two controllers can share a set of partials and render them like this: + # + # <%= render partial: "advertisement/ad", locals: { ad: @advertisement } %> + # + # This will render the partial "advertisement/_ad.html.erb" regardless of which controller this is being called from. + # + # == \Rendering objects that respond to `to_partial_path` + # + # Instead of explicitly naming the location of a partial, you can also let PartialRenderer do the work + # and pick the proper path by checking `to_partial_path` method. + # + # # @account.to_partial_path returns 'accounts/account', so it can be used to replace: + # # <%= render partial: "accounts/account", locals: { account: @account} %> + # <%= render partial: @account %> + # + # # @posts is an array of Post instances, so every post record returns 'posts/post' on `to_partial_path`, + # # that's why we can replace: + # # <%= render partial: "posts/post", collection: @posts %> + # <%= render partial: @posts %> + # + # == \Rendering the default case + # + # If you're not going to be using any of the options like collections or layouts, you can also use the short-hand + # defaults of render to render partials. Examples: + # + # # Instead of <%= render partial: "account" %> + # <%= render "account" %> + # + # # Instead of <%= render partial: "account", locals: { account: @buyer } %> + # <%= render "account", account: @buyer %> + # + # # @account.to_partial_path returns 'accounts/account', so it can be used to replace: + # # <%= render partial: "accounts/account", locals: { account: @account} %> + # <%= render @account %> + # + # # @posts is an array of Post instances, so every post record returns 'posts/post' on `to_partial_path`, + # # that's why we can replace: + # # <%= render partial: "posts/post", collection: @posts %> + # <%= render @posts %> + # + # == \Rendering partials with layouts + # + # Partials can have their own layouts applied to them. These layouts are different than the ones that are + # specified globally for the entire action, but they work in a similar fashion. Imagine a list with two types + # of users: + # + # <%# app/views/users/index.html.erb %> + # Here's the administrator: + # <%= render partial: "user", layout: "administrator", locals: { user: administrator } %> + # + # Here's the editor: + # <%= render partial: "user", layout: "editor", locals: { user: editor } %> + # + # <%# app/views/users/_user.html.erb %> + # Name: <%= user.name %> + # + # <%# app/views/users/_administrator.html.erb %> + # <div id="administrator"> + # Budget: $<%= user.budget %> + # <%= yield %> + # </div> + # + # <%# app/views/users/_editor.html.erb %> + # <div id="editor"> + # Deadline: <%= user.deadline %> + # <%= yield %> + # </div> + # + # ...this will return: + # + # Here's the administrator: + # <div id="administrator"> + # Budget: $<%= user.budget %> + # Name: <%= user.name %> + # </div> + # + # Here's the editor: + # <div id="editor"> + # Deadline: <%= user.deadline %> + # Name: <%= user.name %> + # </div> + # + # If a collection is given, the layout will be rendered once for each item in + # the collection. For example, these two snippets have the same output: + # + # <%# app/views/users/_user.html.erb %> + # Name: <%= user.name %> + # + # <%# app/views/users/index.html.erb %> + # <%# This does not use layouts %> + # <ul> + # <% users.each do |user| -%> + # <li> + # <%= render partial: "user", locals: { user: user } %> + # </li> + # <% end -%> + # </ul> + # + # <%# app/views/users/_li_layout.html.erb %> + # <li> + # <%= yield %> + # </li> + # + # <%# app/views/users/index.html.erb %> + # <ul> + # <%= render partial: "user", layout: "li_layout", collection: users %> + # </ul> + # + # Given two users whose names are Alice and Bob, these snippets return: + # + # <ul> + # <li> + # Name: Alice + # </li> + # <li> + # Name: Bob + # </li> + # </ul> + # + # The current object being rendered, as well as the object_counter, will be + # available as local variables inside the layout template under the same names + # as available in the partial. + # + # You can also apply a layout to a block within any template: + # + # <%# app/views/users/_chief.html.erb %> + # <%= render(layout: "administrator", locals: { user: chief }) do %> + # Title: <%= chief.title %> + # <% end %> + # + # ...this will return: + # + # <div id="administrator"> + # Budget: $<%= user.budget %> + # Title: <%= chief.name %> + # </div> + # + # As you can see, the <tt>:locals</tt> hash is shared between both the partial and its layout. + # + # If you pass arguments to "yield" then this will be passed to the block. One way to use this is to pass + # an array to layout and treat it as an enumerable. + # + # <%# app/views/users/_user.html.erb %> + # <div class="user"> + # Budget: $<%= user.budget %> + # <%= yield user %> + # </div> + # + # <%# app/views/users/index.html.erb %> + # <%= render layout: @users do |user| %> + # Title: <%= user.title %> + # <% end %> + # + # This will render the layout for each user and yield to the block, passing the user, each time. + # + # You can also yield multiple times in one layout and use block arguments to differentiate the sections. + # + # <%# app/views/users/_user.html.erb %> + # <div class="user"> + # <%= yield user, :header %> + # Budget: $<%= user.budget %> + # <%= yield user, :footer %> + # </div> + # + # <%# app/views/users/index.html.erb %> + # <%= render layout: @users do |user, section| %> + # <%- case section when :header -%> + # Title: <%= user.title %> + # <%- when :footer -%> + # Deadline: <%= user.deadline %> + # <%- end -%> + # <% end %> + class PartialRenderer < AbstractRenderer + include CollectionCaching + + PREFIXED_PARTIAL_NAMES = Concurrent::Map.new do |h, k| + h[k] = Concurrent::Map.new + end + + def initialize(*) + super + @context_prefix = @lookup_context.prefixes.first + end + + def render(context, options, block) + setup(context, options, block) + @template = find_partial + + @lookup_context.rendered_format ||= begin + if @template && @template.formats.present? + @template.formats.first + else + formats.first + end + end + + if @collection + render_collection + else + render_partial + end + end + + private + + def render_collection + instrument(:collection, count: @collection.size) do |payload| + return nil if @collection.blank? + + if @options.key?(:spacer_template) + spacer = find_template(@options[:spacer_template], @locals.keys).render(@view, @locals) + end + + cache_collection_render(payload) do + @template ? collection_with_template : collection_without_template + end.join(spacer).html_safe + end + end + + def render_partial + instrument(:partial) do |payload| + view, locals, block = @view, @locals, @block + object, as = @object, @variable + + if !block && (layout = @options[:layout]) + layout = find_template(layout.to_s, @template_keys) + end + + object = locals[as] if object.nil? # Respect object when object is false + locals[as] = object if @has_object + + content = @template.render(view, locals) do |*name| + view._layout_for(*name, &block) + end + + content = layout.render(view, locals) { content } if layout + payload[:cache_hit] = view.view_renderer.cache_hits[@template.virtual_path] + content + end + end + + # Sets up instance variables needed for rendering a partial. This method + # finds the options and details and extracts them. The method also contains + # logic that handles the type of object passed in as the partial. + # + # If +options[:partial]+ is a string, then the +@path+ instance variable is + # set to that string. Otherwise, the +options[:partial]+ object must + # respond to +to_partial_path+ in order to setup the path. + def setup(context, options, block) + @view = context + @options = options + @block = block + + @locals = options[:locals] || {} + @details = extract_details(options) + + prepend_formats(options[:formats]) + + partial = options[:partial] + + if String === partial + @has_object = options.key?(:object) + @object = options[:object] + @collection = collection_from_options + @path = partial + else + @has_object = true + @object = partial + @collection = collection_from_object || collection_from_options + + if @collection + paths = @collection_data = @collection.map { |o| partial_path(o) } + @path = paths.uniq.one? ? paths.first : nil + else + @path = partial_path + end + end + + if as = options[:as] + raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s) + as = as.to_sym + end + + if @path + @variable, @variable_counter, @variable_iteration = retrieve_variable(@path, as) + @template_keys = retrieve_template_keys + else + paths.map! { |path| retrieve_variable(path, as).unshift(path) } + end + + self + end + + def collection_from_options + if @options.key?(:collection) + collection = @options[:collection] + collection ? collection.to_a : [] + end + end + + def collection_from_object + @object.to_ary if @object.respond_to?(:to_ary) + end + + def find_partial + find_template(@path, @template_keys) if @path + end + + def find_template(path, locals) + prefixes = path.include?(?/) ? [] : @lookup_context.prefixes + @lookup_context.find_template(path, prefixes, true, locals, @details) + end + + def collection_with_template + view, locals, template = @view, @locals, @template + as, counter, iteration = @variable, @variable_counter, @variable_iteration + + if layout = @options[:layout] + layout = find_template(layout, @template_keys) + end + + partial_iteration = PartialIteration.new(@collection.size) + locals[iteration] = partial_iteration + + @collection.map do |object| + locals[as] = object + locals[counter] = partial_iteration.index + + content = template.render(view, locals) + content = layout.render(view, locals) { content } if layout + partial_iteration.iterate! + content + end + end + + def collection_without_template + view, locals, collection_data = @view, @locals, @collection_data + cache = {} + keys = @locals.keys + + partial_iteration = PartialIteration.new(@collection.size) + + @collection.map do |object| + index = partial_iteration.index + path, as, counter, iteration = collection_data[index] + + locals[as] = object + locals[counter] = index + locals[iteration] = partial_iteration + + template = (cache[path] ||= find_template(path, keys + [as, counter, iteration])) + content = template.render(view, locals) + partial_iteration.iterate! + content + end + end + + # Obtains the path to where the object's partial is located. If the object + # responds to +to_partial_path+, then +to_partial_path+ will be called and + # will provide the path. If the object does not respond to +to_partial_path+, + # then an +ArgumentError+ is raised. + # + # If +prefix_partial_path_with_controller_namespace+ is true, then this + # method will prefix the partial paths with a namespace. + def partial_path(object = @object) + object = object.to_model if object.respond_to?(:to_model) + + path = if object.respond_to?(:to_partial_path) + object.to_partial_path + else + raise ArgumentError.new("'#{object.inspect}' is not an ActiveModel-compatible object. It must implement :to_partial_path.") + end + + if @view.prefix_partial_path_with_controller_namespace + prefixed_partial_names[path] ||= merge_prefix_into_object_path(@context_prefix, path.dup) + else + path + end + end + + def prefixed_partial_names + @prefixed_partial_names ||= PREFIXED_PARTIAL_NAMES[@context_prefix] + end + + def merge_prefix_into_object_path(prefix, object_path) + if prefix.include?(?/) && object_path.include?(?/) + prefixes = [] + prefix_array = File.dirname(prefix).split("/") + object_path_array = object_path.split("/")[0..-3] # skip model dir & partial + + prefix_array.each_with_index do |dir, index| + break if dir == object_path_array[index] + prefixes << dir + end + + (prefixes << object_path).join("/") + else + object_path + end + end + + def retrieve_template_keys + keys = @locals.keys + keys << @variable if @has_object || @collection + if @collection + keys << @variable_counter + keys << @variable_iteration + end + keys + end + + def retrieve_variable(path, as) + variable = as || begin + base = path[-1] == "/".freeze ? "".freeze : File.basename(path) + raise_invalid_identifier(path) unless base =~ /\A_?(.*?)(?:\.\w+)*\z/ + $1.to_sym + end + if @collection + variable_counter = :"#{variable}_counter" + variable_iteration = :"#{variable}_iteration" + end + [variable, variable_counter, variable_iteration] + end + + IDENTIFIER_ERROR_MESSAGE = "The partial name (%s) is not a valid Ruby identifier; " \ + "make sure your partial name starts with underscore." + + OPTION_AS_ERROR_MESSAGE = "The value (%s) of the option `as` is not a valid Ruby identifier; " \ + "make sure it starts with lowercase letter, " \ + "and is followed by any combination of letters, numbers and underscores." + + def raise_invalid_identifier(path) + raise ArgumentError.new(IDENTIFIER_ERROR_MESSAGE % (path)) + end + + def raise_invalid_option_as(as) + raise ArgumentError.new(OPTION_AS_ERROR_MESSAGE % (as)) + end + end +end diff --git a/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb b/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb new file mode 100644 index 0000000000..db52919e91 --- /dev/null +++ b/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module ActionView + module CollectionCaching # :nodoc: + extend ActiveSupport::Concern + + included do + # Fallback cache store if Action View is used without Rails. + # Otherwise overridden in Railtie to use Rails.cache. + mattr_accessor :collection_cache, default: ActiveSupport::Cache::MemoryStore.new + end + + private + def cache_collection_render(instrumentation_payload) + return yield unless @options[:cached] + + keyed_collection = collection_by_cache_keys + cached_partials = collection_cache.read_multi(*keyed_collection.keys) + instrumentation_payload[:cache_hits] = cached_partials.size + + @collection = keyed_collection.reject { |key, _| cached_partials.key?(key) }.values + rendered_partials = @collection.empty? ? [] : yield + + index = 0 + fetch_or_cache_partial(cached_partials, order_by: keyed_collection.each_key) do + rendered_partials[index].tap { index += 1 } + end + end + + def callable_cache_key? + @options[:cached].respond_to?(:call) + end + + def collection_by_cache_keys + seed = callable_cache_key? ? @options[:cached] : ->(i) { i } + + @collection.each_with_object({}) do |item, hash| + hash[expanded_cache_key(seed.call(item))] = item + end + end + + def expanded_cache_key(key) + key = @view.combined_fragment_cache_key(@view.cache_fragment_name(key, virtual_path: @template.virtual_path)) + key.frozen? ? key.dup : key # #read_multi & #write may require mutability, Dalli 2.6.0. + end + + def fetch_or_cache_partial(cached_partials, order_by:) + order_by.map do |cache_key| + cached_partials.fetch(cache_key) do + yield.tap do |rendered_partial| + collection_cache.write(cache_key, rendered_partial) + end + end + end + end + end +end diff --git a/actionview/lib/action_view/renderer/renderer.rb b/actionview/lib/action_view/renderer/renderer.rb new file mode 100644 index 0000000000..3f3a97529d --- /dev/null +++ b/actionview/lib/action_view/renderer/renderer.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module ActionView + # This is the main entry point for rendering. It basically delegates + # to other objects like TemplateRenderer and PartialRenderer which + # actually renders the template. + # + # The Renderer will parse the options from the +render+ or +render_body+ + # method and render a partial or a template based on the options. The + # +TemplateRenderer+ and +PartialRenderer+ objects are wrappers which do all + # the setup and logic necessary to render a view and a new object is created + # each time +render+ is called. + class Renderer + attr_accessor :lookup_context + + def initialize(lookup_context) + @lookup_context = lookup_context + end + + # Main render entry point shared by Action View and Action Controller. + def render(context, options) + if options.key?(:partial) + render_partial(context, options) + else + render_template(context, options) + end + end + + # Render but returns a valid Rack body. If fibers are defined, we return + # a streaming body that renders the template piece by piece. + # + # Note that partials are not supported to be rendered with streaming, + # so in such cases, we just wrap them in an array. + def render_body(context, options) + if options.key?(:partial) + [render_partial(context, options)] + else + StreamingTemplateRenderer.new(@lookup_context).render(context, options) + end + end + + # Direct access to template rendering. + def render_template(context, options) #:nodoc: + TemplateRenderer.new(@lookup_context).render(context, options) + end + + # Direct access to partial rendering. + def render_partial(context, options, &block) #:nodoc: + PartialRenderer.new(@lookup_context).render(context, options, block) + end + + def cache_hits # :nodoc: + @cache_hits ||= {} + end + end +end diff --git a/actionview/lib/action_view/renderer/streaming_template_renderer.rb b/actionview/lib/action_view/renderer/streaming_template_renderer.rb new file mode 100644 index 0000000000..ca49eb1144 --- /dev/null +++ b/actionview/lib/action_view/renderer/streaming_template_renderer.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "fiber" + +module ActionView + # == TODO + # + # * Support streaming from child templates, partials and so on. + # * Rack::Cache needs to support streaming bodies + class StreamingTemplateRenderer < TemplateRenderer #:nodoc: + # A valid Rack::Body (i.e. it responds to each). + # It is initialized with a block that, when called, starts + # rendering the template. + class Body #:nodoc: + def initialize(&start) + @start = start + end + + def each(&block) + begin + @start.call(block) + rescue Exception => exception + log_error(exception) + block.call ActionView::Base.streaming_completion_on_exception + end + self + end + + private + + # This is the same logging logic as in ShowExceptions middleware. + def log_error(exception) + logger = ActionView::Base.logger + return unless logger + + message = "\n#{exception.class} (#{exception.message}):\n".dup + message << exception.annoted_source_code.to_s if exception.respond_to?(:annoted_source_code) + message << " " << exception.backtrace.join("\n ") + logger.fatal("#{message}\n\n") + end + end + + # For streaming, instead of rendering a given a template, we return a Body + # object that responds to each. This object is initialized with a block + # that knows how to render the template. + def render_template(template, layout_name = nil, locals = {}) #:nodoc: + return [super] unless layout_name && template.supports_streaming? + + locals ||= {} + layout = layout_name && find_layout(layout_name, locals.keys, [formats.first]) + + Body.new do |buffer| + delayed_render(buffer, template, layout, @view, locals) + end + end + + private + + def delayed_render(buffer, template, layout, view, locals) + # Wrap the given buffer in the StreamingBuffer and pass it to the + # underlying template handler. Now, every time something is concatenated + # to the buffer, it is not appended to an array, but streamed straight + # to the client. + output = ActionView::StreamingBuffer.new(buffer) + yielder = lambda { |*name| view._layout_for(*name) } + + instrument(:template, identifier: template.identifier, layout: layout.try(:virtual_path)) do + fiber = Fiber.new do + if layout + layout.render(view, locals, output, &yielder) + else + # If you don't have a layout, just render the thing + # and concatenate the final result. This is the same + # as a layout with just <%= yield %> + output.safe_concat view._layout_for + end + end + + # Set the view flow to support streaming. It will be aware + # when to stop rendering the layout because it needs to search + # something in the template and vice-versa. + view.view_flow = StreamingFlow.new(view, fiber) + + # Yo! Start the fiber! + fiber.resume + + # If the fiber is still alive, it means we need something + # from the template, so start rendering it. If not, it means + # the layout exited without requiring anything from the template. + if fiber.alive? + content = template.render(view, locals, &yielder) + + # Once rendering the template is done, sets its content in the :layout key. + view.view_flow.set(:layout, content) + + # In case the layout continues yielding, we need to resume + # the fiber until all yields are handled. + fiber.resume while fiber.alive? + end + end + end + end +end diff --git a/actionview/lib/action_view/renderer/template_renderer.rb b/actionview/lib/action_view/renderer/template_renderer.rb new file mode 100644 index 0000000000..ce8908924a --- /dev/null +++ b/actionview/lib/action_view/renderer/template_renderer.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/try" + +module ActionView + class TemplateRenderer < AbstractRenderer #:nodoc: + def render(context, options) + @view = context + @details = extract_details(options) + template = determine_template(options) + + prepend_formats(template.formats) + + @lookup_context.rendered_format ||= (template.formats.first || formats.first) + + render_template(template, options[:layout], options[:locals]) + end + + private + + # Determine the template to be rendered using the given options. + def determine_template(options) + keys = options.has_key?(:locals) ? options[:locals].keys : [] + + if options.key?(:body) + Template::Text.new(options[:body]) + elsif options.key?(:plain) + Template::Text.new(options[:plain]) + elsif options.key?(:html) + Template::HTML.new(options[:html], formats.first) + elsif options.key?(:file) + with_fallbacks { find_file(options[:file], nil, false, keys, @details) } + elsif options.key?(:inline) + handler = Template.handler_for_extension(options[:type] || "erb") + Template.new(options[:inline], "inline template", handler, locals: keys) + elsif options.key?(:template) + if options[:template].respond_to?(:render) + options[:template] + else + find_template(options[:template], options[:prefixes], false, keys, @details) + end + else + raise ArgumentError, "You invoked render but did not give any of :partial, :template, :inline, :file, :plain, :html or :body option." + end + end + + # Renders the given template. A string representing the layout can be + # supplied as well. + def render_template(template, layout_name = nil, locals = nil) + view, locals = @view, locals || {} + + render_with_layout(layout_name, locals) do |layout| + instrument(:template, identifier: template.identifier, layout: layout.try(:virtual_path)) do + template.render(view, locals) { |*name| view._layout_for(*name) } + end + end + end + + def render_with_layout(path, locals) + layout = path && find_layout(path, locals.keys, [formats.first]) + content = yield(layout) + + if layout + view = @view + view.view_flow.set(:layout, content) + layout.render(view, locals) { |*name| view._layout_for(*name) } + else + content + end + end + + # This is the method which actually finds the layout using details in the lookup + # context object. If no layout is found, it checks if at least a layout with + # the given name exists across all details before raising the error. + def find_layout(layout, keys, formats) + resolve_layout(layout, keys, formats) + end + + def resolve_layout(layout, keys, formats) + details = @details.dup + details[:formats] = formats + + case layout + when String + begin + if layout.start_with?("/") + with_fallbacks { find_template(layout, nil, false, [], details) } + else + find_template(layout, nil, false, [], details) + end + rescue ActionView::MissingTemplate + all_details = @details.merge(formats: @lookup_context.default_formats) + raise unless template_exists?(layout, nil, false, [], all_details) + end + when Proc + resolve_layout(layout.call(formats), keys, formats) + else + layout + end + end + end +end |