padrino/padrino-framework

View on GitHub
padrino-helpers/lib/padrino/rendering.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'padrino/core_ext/output_safety'
require 'padrino-support'
require 'pathname'

module Padrino
  ##
  # Padrino enhances the Sinatra 'render' method to have support for
  # automatic template engine detection, enhanced layout functionality,
  # locale enabled rendering, among other features.
  #
  module Rendering
    ##
    # A SafeTemplate assumes that its output is safe.
    #
    module SafeTemplate
      def render(*)
        super.html_safe
      end
    end

    ##
    # Exception responsible for when an expected template did not exist.
    #
    class TemplateNotFound < RuntimeError
    end

    ##
    # This is an array of file patterns to ignore. If your editor add a
    # suffix during editing to your files please add it like:
    #
    # @example
    #   Padrino::Rendering::IGNORE_FILE_PATTERN << /~$/
    #
    IGNORE_FILE_PATTERN = [
      /~$/ # This is for Gedit
    ] unless defined?(IGNORE_FILE_PATTERN)

    ##
    # Defines common content-type alias mappings.
    #
    CONTENT_TYPE_ALIASES = { :htm => :html }

    ##
    # Default options used in the resolve_template-method.
    #
    DEFAULT_RENDERING_OPTIONS = { :strict_format => false, :raise_exceptions => true } unless defined?(DEFAULT_RENDERING_OPTIONS)

    class << self
      ##
      # Default engine configurations for Padrino::Rendering.
      #
      # @return {Hash<Symbol,Hash>}
      #   The configurations, keyed by engine.
      def engine_configurations
        @engine_configurations ||= {}
      end

      def registered(app)
        if defined?(Padrino::Application) && app == Padrino::Application
          # this fail can be removed later when jRuby is not bugged and MRI19 is dropped
          fail 'Please, do not use `register` on Padrino::Application object, use `.dup` or subclassing'
        end
        included(app)
        engine_configurations.each do |engine, configs|
          app.set engine, configs
        end
      end

      def included(base)
        base.send(:include, InstanceMethods)
        base.extend(ClassMethods)
      end
    end

    ##
    # Class methods responsible for rendering templates as part of a request.
    #
    module ClassMethods
      ##
      # Use layout like rails does or if a block given then like sinatra.
      # If used without a block, sets the current layout for the route.
      #
      # By default, searches in your:
      #
      # +app+/+views+/+layouts+/+application+.(+haml+|+erb+|+xxx+)
      # +app+/+views+/+layout_name+.(+haml+|+erb+|+xxx+)
      #
      # If you define +layout+ :+custom+ then searches for your layouts in
      # +app+/+views+/+layouts+/+custom+.(+haml+|+erb+|+xxx+)
      # +app+/+views+/+custom+.(+haml+|+erb+|+xxx+)
      #
      # @param [Symbol] name (:layout)
      #   The layout to use.
      #
      # @yield []
      #
      def layout(name=:layout, &block)
        return super(name, &block) if block_given?
        @layout = name
      end

      ##
      # Returns the cached layout path.
      #
      # @param [String, nil] given_layout
      #   The requested layout.
      # @param [String, nil] layouts_path
      #   The directory where the layouts are located. Defaults to #views.
      #
      def fetch_layout_path(given_layout, layouts_path=views)
        layout_name = (given_layout || @layout || :application).to_s
        cache_layout_path(layout_name) do
          if Pathname.new(layout_name).absolute? && Dir["#{layout_name}.*"].any? || Dir["#{layouts_path}/#{layout_name}.*"].any?
            layout_name
          else
            File.join('layouts', layout_name)
          end
        end
      end

      def cache_layout_path(name)
        @_cached_layout ||= {}
        if !reload_templates? && path = @_cached_layout[name]
          path
        else
          @_cached_layout[name] = yield(name)
        end
      end

      def cache_template_path(options)
        began_at = Time.now
        @_cached_templates ||= {}
        logging = defined?(settings) && settings.logging? && defined?(logger)
        if !reload_templates? && path = @_cached_templates[options]
          logger.debug :cached, began_at, path[0] if logging
        else
          path = @_cached_templates[options] = yield(name)
          logger.debug :template, began_at, path[0] if path && logging
        end
        path
      end
    end

    # Instance methods that allow enhanced rendering to function properly in Padrino.
    module InstanceMethods
      attr_reader :current_engine

      ##
      # Get/Set the content_type
      #
      # @param [String, nil] type
      #   The Content-Type to use.
      #
      # @param [Symbol, nil] type.
      #   Look and parse the given symbol to the matched Content-Type.
      #
      # @param [Hash] params
      #   Additional params to append to the Content-Type.
      #
      # @example
      #   case content_type
      #     when :js then do_some
      #     when :css then do_another
      #   end
      #
      #   content_type :js
      #   # => set the response with 'application/javascript' Content-Type
      #   content_type 'text/html'
      #
      #   # => set directly the Content-Type to 'text/html'
      #
      def content_type(type=nil, params={})
        if type
          super(type, params)
          @_content_type = type
        end
        @_content_type
      end

      private

      def render_like_sinatra(engine, data, options={}, locals={}, &block)
        # merge app-level options
        engine_options = settings.respond_to?(engine) ? settings.send(engine) : {}
        options = engine_options.merge(options)

        # extract generic options
        locals          = options.delete(:locals) || locals         || {}
        views           = options.delete(:views)  || settings.views || "./views"
        layout          = options[:layout]
        layout          = false if layout.nil? && options.include?(:layout)
        eat_errors      = layout.nil?
        layout          = engine_options[:layout] if layout.nil? or (layout == true && engine_options[:layout] != false)
        layout          = @default_layout         if layout.nil? or layout == true
        layout_options  = options.delete(:layout_options) || {}
        content_type    = options.delete(:default_content_type)
        content_type    = options.delete(:content_type)   || content_type
        layout_engine   = options.delete(:layout_engine)  || engine
        scope           = options.delete(:scope)          || self
        options.delete(:layout)

        # set some defaults
        options[:default_encoding] ||= settings.default_encoding

        # compile and render template
        begin
          layout_was      = @default_layout
          @default_layout = false
          template        = compile_template(engine, data, options, views)
          output          = template.render(scope, locals, &block)
        ensure
          @default_layout = layout_was
        end

        # render layout
        if layout
          layout_engine_options = settings.respond_to?(layout_engine) ? settings.send(layout_engine).dup : {}
          options = layout_engine_options.update(:views => views, :layout => false, :eat_errors => eat_errors, :scope => scope).update(layout_options)
          catch(:layout_missing) { return render_like_sinatra(layout_engine, layout, options, locals) { output } }
        end

        output.extend(Sinatra::Base::ContentTyped).content_type = content_type if content_type
        output
      end

      ##
      # Enhancing Sinatra render functionality for:
      #
      # * Using layout similar to rails
      # * Use render 'path/to/my/template'   (without symbols)
      # * Use render 'path/to/my/template'   (with engine lookup)
      # * Use render 'path/to/template.haml' (with explicit engine lookup)
      # * Use render 'path/to/template', :layout => false
      # * Use render 'path/to/template', :layout => false, :engine => 'haml'
      #
      def render(engine, data=nil, options={}, locals={}, &block)
        # If engine is nil, ignore engine parameter and shift up all arguments
        # render nil, "index", { :layout => true }, { :localvar => "foo" }
        engine, data, options = data, options, locals if engine.nil? && data

        # Data is a hash of options when no engine isn't explicit
        # render "index", { :layout => true }, { :localvar => "foo" }
        # Data is options, and options is locals in this case
        data, options, locals = nil, data, options if data.is_a?(Hash)

        # If data is unassigned then this is a likely a template to be resolved
        # This means that no engine was explicitly defined
        data, engine = resolve_template(engine, options) if data.nil?

        ensure_rendering_engine(engine) || (options[:layout] ||= @layout || false)

        # Cleanup the template.
        @current_engine, engine_was = engine, @current_engine
        @_out_buf,  buf_was = SafeBuffer.new, @_out_buf

        # Pass arguments to Sinatra render method.
        render_like_sinatra(engine, data, with_layout(options), locals, &block)
      ensure
        @current_engine = engine_was
        @_out_buf = buf_was
      end

      ##
      # Returns the template path and engine that match content_type (if present),
      # I18n.locale.
      #
      # @param [String] template_path
      #   The path of the template.
      #
      # @param [Hash] options
      #   Additional options.
      #
      # @option options [Boolean] :strict_format (false)
      #   The resolved template must match the content_type of the request.
      #
      # @option options [Boolean] :raise_exceptions (false)
      #   Raises a {TemplateNotFound} exception if the template cannot be located.
      #
      # @return [Array<Symbol, Symbol>]
      #   The path and format of the template.
      #
      # @raise [TemplateNotFound]
      #   The template could not be found.
      #
      # @example
      #   get "/foo", :provides => [:html, :js] do; render 'path/to/foo'; end
      #   # If you request "/foo.js" with I18n.locale == :ru => [:"/path/to/foo.ru.js", :erb]
      #   # If you request "/foo" with I18n.locale == :de => [:"/path/to/foo.de.haml", :haml]
      #
      def resolve_template(template_path, options={})
        template_path = template_path.to_s
        controller_key = respond_to?(:request) && request.respond_to?(:controller) && request.controller
        rendering_options = [template_path, content_type || :html, locale]

        settings.cache_template_path(["#{controller_key}/#{template_path}", rendering_options[1], rendering_options[2]]) do
          options = DEFAULT_RENDERING_OPTIONS.merge(options)
          view_path = options[:views] || settings.views || "./views"

          template_candidates = glob_templates(view_path, template_path)
          selected_template = select_template(template_candidates, *rendering_options)
          selected_template ||= template_candidates.first unless options[:strict_format]

          fail TemplateNotFound, "Template '#{template_path}' not found in '#{view_path}'"  if !selected_template && options[:raise_exceptions]
          selected_template
        end
      end

      ##
      # Return the I18n.locale if I18n is defined.
      #
      def locale
        I18n.locale if defined?(I18n)
      end

      def resolve_layout(layout, options={})
        layouts_path = options[:layout_options] && options[:layout_options][:views] || options[:views] || settings.views || "./views"
        template_path = settings.fetch_layout_path(layout, layouts_path)
        rendering_options = [template_path, content_type || :html, locale]

        settings.cache_template_path(rendering_options) do
          template_candidates = glob_templates(layouts_path, template_path)
          selected_template = select_template(template_candidates, *rendering_options)

          fail TemplateNotFound, "Layout '#{template_path}' not found in '#{layouts_path}'" if !selected_template && layout
          selected_template
        end
      end

      def with_layout(options)
        options = options.dup
        layout = options[:layout]
        return options if layout == false

        layout = @layout if !layout || layout == true
        return options if settings.templates.has_key?(:layout) && !layout

        if layout.kind_of?(String) && Pathname.new(layout).absolute?
          layout_path, _, layout = layout.rpartition('/')
          options[:layout_options] ||= {}
          options[:layout_options][:views] ||= layout_path
        end
        layout, layout_engine = resolve_layout(layout, options)
        options.update(:layout => layout, :layout_engine => layout_engine)
      end

      def glob_templates(views_path, template_path)
        parts = []
        parts << views_path if views_path
        if respond_to?(:request) && request.respond_to?(:controller) && request.controller && Pathname.new(template_path).relative?
          parts << "{,#{request.controller}}"
        end
        parts << template_path.chomp(File.extname(template_path)) + '.*'
        Dir.glob(File.join(parts)).inject([]) do |all,file|
          next all if IGNORE_FILE_PATTERN.any?{ |pattern| file.to_s =~ pattern }
          all << path_and_engine(file, views_path)
        end
      end

      def select_template(templates, template_path, content_type, _locale)
        symbol = content_type_symbol(content_type)
        simple_content_type = [:html, :plain].include?(symbol)
        target_path, target_engine = path_and_engine(template_path)

        templates.find{ |file,_| file.to_s == "#{target_path}.#{locale}.#{symbol}" } ||
        templates.find{ |file,_| file.to_s == "#{target_path}.#{locale}" && simple_content_type } ||
        templates.find{ |file,engine| engine == target_engine || File.extname(file.to_s) == ".#{target_engine}" } ||
        templates.find{ |file,_| file.to_s == "#{target_path}.#{symbol}" } ||
        templates.find{ |file,_| file.to_s == "#{target_path}" && simple_content_type }
      end

      def path_and_engine(path, relative=nil)
        extname = File.extname(path)
        engine = (extname[1..-1]||'none').to_sym
        path = path.chomp(extname)
        path.insert(0, '/') unless Pathname.new(path).absolute?
        path = path.squeeze('/').sub(relative, '') if relative
        [path.to_sym, engine.to_sym]
      end

      def ensure_rendering_engine(engine)
        return true if settings.respond_to?(engine)
        return nil unless engine == :erb
        require 'erb'
      rescue LoadError
      else
        require 'padrino/rendering/erb_template'
        settings.set :erb, Padrino::Rendering.engine_configurations[:erb]
      end

      def content_type_symbol(type)
        if defined?(::Rack::Mime::MIME_TYPES) && type.kind_of?(String) &&
           ::Rack::Mime::MIME_TYPES.key(type)
          type = ::Rack::Mime::MIME_TYPES.key(type).sub(/\./,'').to_sym
        end
        CONTENT_TYPE_ALIASES[type] || type
      end
    end
  end
end

unless defined? Padrino::Rendering::HamlTemplate
  begin
    require 'haml'
    require 'haml/helpers/xss_mods'
    require 'haml/helpers/action_view_extensions'
  rescue LoadError
  else
    require 'padrino/rendering/haml_template'
  end
end

unless defined? Padrino::Rendering::HamlitTemplate
  begin
    require 'hamlit'
  rescue LoadError
  else
    require 'padrino/rendering/hamlit_template'
  end
end

unless defined? Padrino::Rendering::ErubisTemplate
  begin
    require 'erubis'
  rescue LoadError
  else
    require 'padrino/rendering/erubis_template'
  end
end

unless defined? Padrino::Rendering::SlimTemplate
  begin
    require 'slim'
  rescue LoadError
  else
    require 'padrino/rendering/slim_template'
  end
end

unless defined? Padrino::Rendering::ErubiTemplate
  begin
    require 'erubi'
  rescue LoadError
  else
    require 'padrino/rendering/erubi_template'
  end
end