lib/middleman-core/template_renderer.rb



require 'tilt'
require 'active_support/core_ext/string/output_safety'
require 'middleman-core/template_context'
require 'middleman-core/file_renderer'
require 'middleman-core/contracts'

module Middleman
  class TemplateRenderer
    extend Forwardable
    include Contracts

    class Cache
      def initialize
        @cache = {}
      end

      def fetch(*key)
        @cache[key] = yield unless @cache.key?(key)
        @cache[key]
      end

      def clear
        @cache = {}
      end
    end

    def self.cache
      @_cache ||= Cache.new
    end

    # Custom error class for handling
    class TemplateNotFound < RuntimeError; end

    def initialize(app, path)
      @app = app
      @path = path
    end

    # Render a template, with layout, given a path
    #
    # @param [Hash] locs
    # @param [Hash] opts
    # @return [String]
    Contract Hash, Hash => String
    def render(locs={}, opts={}, &block)
      path = @path.dup
      locals = locs.dup.freeze
      options = opts.dup

      extension = File.extname(path)
      engine = extension[1..-1].to_sym

      if defined?(::I18n)
        old_locale = ::I18n.locale
        ::I18n.locale = options[:locale] if options[:locale]

        # Backwards compat
        ::I18n.locale = options[:lang] if options[:lang]
      end

      # Sandboxed class for template eval
      context = @app.template_context_class.new(@app, locals, options)

      # Add extension helpers to context.
      @app.extensions.add_exposed_to_context(context)

      content = _render_with_all_renderers(path, locs, context, opts, &block)

      # If we need a layout and have a layout, use it
      if layout_file = fetch_layout(engine, options)
        layout_renderer = ::Middleman::FileRenderer.new(@app, layout_file[:relative_path].to_s)
        content = layout_renderer.render(locals, options, context) { content }
      end

      # Return result
      content
    ensure
      # Pop all the saved variables from earlier as we may be returning to a
      # previous render (layouts, partials, nested layouts).
      ::I18n.locale = old_locale if defined?(::I18n)
    end

    protected

    def _render_with_all_renderers(path, locs, context, opts, &block)
      # Keep rendering template until we've used up all extensions. This
      # handles cases like `style.css.sass.erb`
      content = nil

      while ::Tilt[path]
        begin
          opts[:template_body] = content if content

          content_renderer = ::Middleman::FileRenderer.new(@app, path)
          content = content_renderer.render(locs, opts, context, &block)

          path = File.basename(path, File.extname(path))
        rescue LocalJumpError
          raise "Tried to render a layout (calls yield) at #{path} like it was a template. Non-default layouts need to be in #{@app.config[:source]}/#{@app.config[:layouts_dir]}."
        end
      end

      content
    end

    # Find a layout for a given engine
    #
    # @param [Symbol] engine
    # @param [Hash] opts
    # @return [String, Boolean]
    Contract Symbol, Hash => Maybe[IsA['Middleman::SourceFile']]
    def fetch_layout(engine, opts)
      # The layout name comes from either the system default or the options
      local_layout = opts.key?(:layout) ? opts[:layout] : @app.config[:layout]
      return unless local_layout

      # Look for engine-specific options
      engine_options = @app.config.respond_to?(engine) ? @app.config.send(engine) : {}

      # The engine for the layout can be set in options, engine_options or passed
      # into this method
      layout_engine = if opts.key?(:layout_engine)
        opts[:layout_engine]
      elsif engine_options.key?(:layout_engine)
        engine_options[:layout_engine]
      else
        engine
      end

      # Automatic mode
      if local_layout == :_auto_layout
        # Look for :layout of any extension
        # If found, use it. If not, continue
        locate_layout(:layout, layout_engine)
      else
        # Look for specific layout
        # If found, use it. If not, error.
        if layout_file = locate_layout(local_layout, layout_engine)
          layout_file
        else
          raise ::Middleman::TemplateRenderer::TemplateNotFound, "Could not locate layout: #{local_layout}"
        end
      end
    end

    # Find a layout on-disk, optionally using a specific engine
    # @param [String] name
    # @param [Symbol] preferred_engine
    # @return [String]
    Contract Or[String, Symbol], Symbol => Maybe[IsA['Middleman::SourceFile']]
    def locate_layout(name, preferred_engine=nil)
      self.class.locate_layout(@app, name, preferred_engine)
    end

    # Find a layout on-disk, optionally using a specific engine
    # @param [String] name
    # @param [Symbol] preferred_engine
    # @return [String]
    Contract IsA['Middleman::Application'], Or[String, Symbol], Symbol => Maybe[IsA['Middleman::SourceFile']]
    def self.locate_layout(app, name, preferred_engine=nil)
      resolve_opts = {}
      resolve_opts[:preferred_engine] = preferred_engine unless preferred_engine.nil?

      # Check layouts folder
      layout_file = resolve_template(app, File.join(app.config[:layouts_dir], name.to_s), resolve_opts)

      # If we didn't find it, check root
      layout_file = resolve_template(app, name, resolve_opts) unless layout_file

      # Return the path
      layout_file
    end

    # Find a template on disk given a output path
    # @param [String] request_path
    # @param [Hash] options
    # @return [Array<String, Symbol>, Boolean]
    Contract String, Hash => ArrayOf[Or[String, Symbol]]
    def resolve_template(request_path, options={})
      self.class.resolve_template(@app, request_path, options)
    end

    # Find a template on disk given a output path
    # @param [String] request_path
    # @option options [Boolean] :preferred_engine If set, try this engine first, then fall back to any engine.
    # @return [String, Boolean] Either the path to the template, or false
    Contract IsA['Middleman::Application'], Or[Symbol, String], Maybe[Hash] => Maybe[IsA['Middleman::SourceFile']]
    def self.resolve_template(app, request_path, options={})
      # Find the path by searching
      relative_path = Util.strip_leading_slash(request_path.to_s)

      # By default, any engine will do
      preferred_engines = []

      # If we're specifically looking for a preferred engine
      if options.key?(:preferred_engine)
        extension_class = ::Tilt[options[:preferred_engine]]

        # Get a list of extensions for a preferred engine
        preferred_engines += ::Tilt.mappings.select do |_, engines|
          engines.include? extension_class
        end.keys
      end

      preferred_engines << '*'
      preferred_engines << nil if options[:try_static]

      found_template = nil

      preferred_engines.each do |preferred_engine|
        path_with_ext = relative_path.dup
        path_with_ext << ('.' + preferred_engine) unless preferred_engine.nil?

        globbing = preferred_engine == '*'

        # Cache lookups in build mode only
        file = if app.build?
          cache.fetch(path_with_ext, preferred_engine) do
            app.files.find(:source, path_with_ext, globbing)
          end
        else
          app.files.find(:source, path_with_ext, globbing)
        end

        found_template = file if file && (preferred_engine.nil? || ::Tilt[file[:full_path]])
        break if found_template
      end

      # If we found one, return it
      found_template
    end
  end
end