lib/keynote/inline.rb

Summary

Maintainability
A
50 mins
Test Coverage
# encoding: UTF-8

require "action_view"
require "thread"

module Keynote
  # The `Inline` mixin lets you write inline templates as comments inside the
  # body of a presenter method. You can use any template language supported by
  # Rails.
  #
  # ## Basic usage
  #
  # After extending the `Keynote::Inline` module in your presenter class, you
  # can generate HTML by calling the `erb` method and immediately following it
  # with a block of comments containing your template:
  #
  #     def link
  #       erb
  #       # <%= link_to user_url(current_user) do %>
  #       #   <%= image_tag("image1.jpg") %>
  #       #   <%= image_tag("image2.jpg") %>
  #       # <% end %>
  #     end
  #
  # Calling this method renders the ERB template, including passing the calls
  # to `link_to`, `user_url`, `current_user`, and `image_tag` back to the
  # presenter object (and then to the view).
  #
  # ## Passing variables
  #
  # There are a couple of different ways to pass local variables into an inline
  # template. The easiest is to pass the `binding` object into the template
  # method, giving access to all local variables:
  #
  #     def local_binding
  #       x = 1
  #       y = 2
  #
  #       erb binding
  #       # <%= x + y %>
  #     end
  #
  # You can also pass a hash of variable names and values instead:
  #
  #     def local_binding
  #       erb x: 1, y: 2
  #       # <%= x + y %>
  #     end
  #
  # ## The `inline` method
  #
  # If you want to use template languages other than ERB, you have to define
  # methods for them by calling the {Keynote::Inline#inline} method on a
  # presenter class:
  #
  #     class MyPresenter < Keynote::Presenter
  #       extend Keynote::Inline
  #       presents :user, :account
  #       inline :haml
  #     end
  #
  # This defines a `#haml` instance method on the `MyPresenter` class.
  #
  # If you want to make inline templates available to all of your presenters,
  # you can add an initializer like this to your application:
  #
  #     class Keynote::Presenter
  #       extend Keynote::Inline
  #       inline :haml, :slim
  #     end
  #
  # This will add `#erb`, `#haml`, and `#slim` instance methods to all of your
  # presenters.
  module Inline
    # For each template format given as a parameter, add an instance method
    # that can be called to render an inline template in that format. Any
    # file extension supported by Rails is a valid parameter.
    # @example
    #   class UserPresenter < Keynote::Presenter
    #     presents :user
    #     inline :haml
    #
    #     def header
    #       full_name = "#{user.first_name} #{user.last_name}"
    #
    #       haml binding
    #       # div#header
    #       #   h1= full_name
    #       #   h3= user.most_recent_status
    #     end
    #   end
    def inline(*formats)
      require "action_view/context"

      Array(formats).each do |format|
        define_method format do |locals = {}|
          Renderer.new(self, locals, caller(1)[0], format).render
        end
      end
    end

    # Extending `Keynote::Inline` automatically creates an `erb` method on the
    # base class.
    def self.extended(base)
      base.inline :erb
    end

    # @private
    class Renderer
      def initialize(presenter, locals, caller_line, format)
        @presenter = presenter
        @locals = extract_locals(locals)
        @template = Cache.fetch(*parse_caller(caller_line), format, @locals)
      end

      def render
        @template.render(@presenter, @locals)
      end

      private

      def extract_locals(locals)
        return locals unless locals.is_a?(Binding)

        Hash[locals.eval("local_variables").map do |local|
          [local, locals.eval(local.to_s)]
        end]
      end

      def parse_caller(caller_line)
        file, rest = caller_line.split ":", 2
        line, _    = rest.split " ", 2

        [file.strip, line.to_i]
      end
    end

    # @private
    class Cache
      COMMENTED_LINE = /^\s*#(.*)$/

      def self.fetch(source_file, line, format, locals)
        instance = (Thread.current[:_keynote_template_cache] ||= Cache.new)
        instance.fetch(source_file, line, format, locals)
      end

      def self.reset
        Thread.current[:_keynote_template_cache] = nil
      end

      def initialize
        @cache = {}
      end

      def fetch(source_file, line, format, locals)
        local_names = locals.keys.sort
        cache_key   = ["#{source_file}:#{line}", *local_names].freeze
        new_mtime   = File.mtime(source_file).to_f

        template, mtime = @cache[cache_key]

        if new_mtime != mtime
          source = read_template(source_file, line)

          template = Template.new(source, cache_key[0],
            handler_for_format(format), locals: local_names)

          @cache[cache_key] = [template, new_mtime]
        end

        template
      end

      private

      def read_template(source_file, line_num)
        result = ""

        File.foreach(source_file).drop(line_num).each do |line|
          if line =~ COMMENTED_LINE
            result << $1 << "\n"
          else
            break
          end
        end

        unindent result.chomp
      end

      def handler_for_format(format)
        ActionView::Template.handler_for_extension(format.to_s)
      end

      # Borrowed from Pry, which borrowed it from Python.
      def unindent(text, left_padding = 0)
        margin = text.scan(/^[ \t]*(?=[^ \t\n])/).inject do |current_margin, next_indent|
          if next_indent.start_with?(current_margin)
            current_margin
          elsif current_margin.start_with?(next_indent)
            next_indent
          else
            ""
          end
        end

        text.gsub(/^#{margin}/, ' ' * left_padding)
      end
    end

    # @private
    class TemplateFor41AndLower < ActionView::Template
      # Older versions of Rails don't have this mutex, but we probably want it,
      # so let's make sure it's there.
      def initialize(*)
        super
        @compile_mutex = Mutex.new
      end

      # The only difference between this #compile! and the normal one is that
      # we call `view.class` instead of `view.singleton_class`, so that the
      # template method gets defined as an instance method on the presenter
      # and therefore sticks around between presenter instances.
      def compile!(view)
        return if @compiled

        @compile_mutex.synchronize do
          return if @compiled

          compile(view, view.class)

          @source = nil if defined?(@virtual_path) && @virtual_path
          @compiled = true
        end
      end
    end

    # @private
    class TemplateFor42AndHigher < ActionView::Template
      # The only difference between this #compile! and the normal one is that
      # we call `view.class` instead of `view.singleton_class`, so that the
      # template method gets defined as an instance method on the presenter
      # and therefore sticks around between presenter instances.
      def compile!(view)
        return if @compiled

        @compile_mutex.synchronize do
          return if @compiled

          compile(view.class)

          @source = nil if defined?(@virtual_path) && @virtual_path
          @compiled = true
        end
      end
    end

    if Rails.version.to_f < 4.2
      # @private
      Template = TemplateFor41AndLower
    else
      # @private
      Template = TemplateFor42AndHigher
    end
  end
end