ujifgc/rack-pipeline

View on GitHub
lib/rack-pipeline/base.rb

Summary

Maintainability
A
1 hr
Test Coverage
require 'time'

require 'rack-pipeline/version'
require 'rack-pipeline/caching'
require 'rack-pipeline/processing'

module RackPipeline
  class MustRepopulate < Exception; end
  class Base
    include Caching
    include Processing

    attr_accessor :assets, :settings

    STATIC_TYPES  = { '.js' => :js,                      '.css' => :css       }.freeze
    CONTENT_TYPES = { '.js' => 'application/javascript', '.css' => 'text/css' }.freeze

    def assets_for(pipes, type, opts = {})
      Array(pipes).inject([]) do |all,pipe|
        all += Array(settings[:combine] ? "#{pipe}.#{type}" : assets[type][pipe].keys)
      end.compact.uniq
    end

    def initialize(app, *args)
      @generations = 0
      @assets = {}
      @settings = {
        :temp => nil,
        :compress => false,
        :combine => false,
        :bust_cache => false,
        :css => {
          :app => 'assets/**/*.css',
        },
        :js => {
          :app => 'assets/**/*.js',
        },
      }
      @settings.merge!(args.pop)  if args.last.kind_of?(Hash)
      ensure_temp_directory
      populate_pipelines
      @app = app
    end

    def inspect
      { :settings => settings, :assets => assets }
    end

    def call(env)
      @env = env
      env['rack-pipeline'] = self
      if file_path = prepare_pipe(env['PATH_INFO'])
        serve_file(file_path, env['HTTP_IF_MODIFIED_SINCE'])
      else
        @app.call(env)
      end
    rescue MustRepopulate
      populate_pipelines
      retry
    end

    private

    def busted?
      result = settings[:bust_cache] && @busted
      @busted = false
      result
    end

    def serve_file(file, mtime)
      headers = { 'Last-Modified' => File.mtime(file).httpdate }
      if mtime == headers['Last-Modified']
        [304, headers, []]
      else
        if busted?
          headers['Location'] = "#{@env['PATH_INFO']}?#{File.mtime(file).to_i}"
          [302, headers, []]
        else
          body = File.read file
          headers['Content-Type'] = "#{content_type(file)}; charset=#{body.encoding.to_s}"
          headers['Content-Length'] = File.size(file).to_s
          [200, headers, [body]]
        end
      end
    rescue Errno::ENOENT
      raise MustRepopulate
    end

    def static_type(file)
      if file.kind_of? String
        STATIC_TYPES[file] || STATIC_TYPES[File.extname(file)]
      else
        STATIC_TYPES.values.include?(file) && file
      end
    end

    def content_type(file)
      CONTENT_TYPES[File.extname(file)] || 'text'
    end

    def prepare_pipe(path_info)
      file = path_info.start_with?('/') ? path_info[1..-1] : path_info
      type = static_type(file)  or return nil
      unless ready_file = prepare_file(file, type)
        pipename = File.basename(file, '.*').to_sym
        if assets[type] && assets[type][pipename]
          ready_file = combine(assets[type][pipename], File.basename(file))
        end
      end
      compress(ready_file, File.basename(ready_file))  if ready_file
    rescue Errno::ENOENT
      raise MustRepopulate
    end

    def prepare_file(source, type)
      assets[type].each do |pipe,files|
        case files[source]
        when :raw
          return source
        when :source
          return compile(source, File.basename(source, '.*') + ".#{type}")
        end
      end
      nil
    end

    def file_kind(file)
      static_type(file) ? :raw : :source
    end

    def glob_files(globs)
      Array(globs).each_with_object({}) do |glob,all|
        Dir.glob(glob).sort.each do |file|
          all[file] = file_kind(file)
        end
      end
    end

    def populate_pipelines
      fail SystemStackError, 'too many RackPipeline generations'  if @generations > 5
      @generations += 1
      STATIC_TYPES.each do |extname,type|
        pipes = settings[type]
        assets[type] = {}
        pipes.each do |pipe, dirs|
          assets[type][pipe] = glob_files(dirs)
        end
      end
    end
  end
end