rstacruz/sinatra-assetpack

View on GitHub
lib/sinatra/assetpack/options.rb

Summary

Maintainability
A
3 hrs
Test Coverage
module Sinatra
  module AssetPack
    # Assets.
    #
    # == Common usage
    #
    #     SinatraApp.assets {
    #       # dsl stuff here
    #     }
    #
    #     a = SinatraApp.assets
    #
    # Getting options:
    #
    #     a.js_compression
    #     a.output_path
    #
    # Served:
    #
    #     a.served         # { '/js' => '/var/www/project/app/js', ... }
    #                      # (URL path => local path)
    #
    # Packages:
    #
    #     a.packages       # { 'app.css' => #<Package>, ... }
    #                      # (name.type => package instance)
    #
    # Build:
    #
    #     a.build! { |path| puts "Building #{path}" }
    #     a.build_packages! { |path| puts "Building package #{path}" }
    #     a.build_files! { |path| puts "Building files #{path}" }
    #
    # Lookup:
    #
    #     a.local_path_for('/images/bg.gif')
    #     a.served?('/images/bg.gif')
    #
    #     a.glob(['/js/*.js', '/js/vendor/**/*.js'])
    #     # Returns a HashArray of (local => remote)
    #
    class Options
      include Builder
      include Configurator

      def initialize(app, &blk)
        unless app.root?
          raise Error, "Please set :root in your Sinatra app."
        end

        @app             = app
        @js_compression  = :jsmin
        @css_compression = :simple
        @reload_files_cache = true

        begin
          @output_path   = app.public
        rescue NoMethodError
          @output_path   = app.public_folder
        end

        @js_compression_options  = Hash.new
        @css_compression_options = Hash.new
        @dynamic_asset_cache     = Hash.new

        @ignored = Array.new

        reset!

        # Defaults!
        serve '/css',    :from => 'app/css'     rescue Errno::ENOTDIR
        serve '/js',     :from => 'app/js'      rescue Errno::ENOTDIR
        serve '/images', :from => 'app/images'  rescue Errno::ENOTDIR

        ignore '.*'
        ignore '_*'

        blk.arity <= 0 ? instance_eval(&blk) : yield(self)  if block_given?
      end

      # =====================================================================
      # DSL methods

      def serve(path, options={})
        unless from = options[:from]
          raise ArgumentError, "Parameter :from is required" 
        end

        expanded = expand_from(from)

        unless File.directory? expanded
          raise Errno::ENOTDIR, expanded
        end

        @served[path] = from
        @reload_files_cache = true
      end

      # Undo defaults.
      def reset!
        @served   = Hash.new
        @packages = Hash.new
        @reload_files_cache = true
      end

      # Ignores a given path spec.
      def ignore(spec)
        if spec[0] == '/'
          @ignored << "#{spec}"
          @ignored << "#{spec}/**"
        else
          @ignored << "**/#{spec}"
          @ignored << "**/#{spec}/**"
        end
      end

      # Makes nothing ignored. Use this if you don't want to ignore dotfiles and underscore files.
      def clear_ignores!
        @ignored  = Array.new
      end

      # Checks if a given path is ignored.
      def ignored?(fn)
        @ignored.any? { |spec| File.fnmatch spec, fn }
      end

      # Adds some JS packages.
      #
      #     js :foo, [ '/js/vendor/jquery.*.js', ... ]
      #     js :foo, '/js/foo.js', [ '/js/vendor/jquery.*.js', ... ]
      #
      def js(name, *args)
        js_or_css :js, name, *args
      end

      # Adds some CSS packages.
      #
      #     css :app, [ '/css/screen.css', ... ]
      #     css :app, '/css/app.css', [ '/css/screen.css', ... ]
      #
      def css(name, *args) #path, files=[])
        js_or_css :css, name, *args
      end

      def js_or_css(type, name, *args)
        # Account for "css :name, '/path/to/css', [ files ]"
        if args[0].is_a?(String) && args[1].respond_to?(:each)
          path, files = args

        # Account for "css :name, [ files ]"
        elsif args[0].respond_to?(:each)
          path  = "/assets/#{name}.#{type}" # /assets/foobar.css by default
          files = args[0]

        else
          raise ArgumentError
        end

        @packages["#{name}.#{type}"] = Package.new(self, name, type, path, files)
      end

      attr_reader   :app        # Sinatra::Base instance
      attr_reader   :packages   # Hash, keys are "foo.js", values are Packages
      attr_reader   :served     # Hash, paths to be served.
                                # Key is URI path, value is local path

      attrib :js_compression    # Symbol, compression method for JS
      attrib :css_compression   # Symbol, compression method for CSS
      attrib :output_path       # '/public'
      attrib :asset_hosts       # [ 'http://cdn0.example.org', 'http://cdn1.example.org' ]

      attrib :js_compression_options   # Hash
      attrib :css_compression_options  # Hash

      attrib :prebuild          # Bool
      attrib :cache_dynamic_assets # Bool

      def expires(*args)
        if args.empty?
          @expires
        else
          @expires = args
        end
      end

      def js_compression(name=nil, options=nil)
        @js_compression = name unless name.nil?
        @js_compression_options = options if options.is_a?(Hash)
        @js_compression
      end

      def css_compression(name=nil, options=nil)
        @css_compression = name unless name.nil?
        @css_compression_options = options if options.is_a?(Hash)
        @css_compression
      end

      # =====================================================================
      # Stuff

      attr_reader :served

      # Caches the packages.
      def cache!(&blk)
        return if app.reload_templates

        session = Rack::Test::Session.new app
        packages.each { |_, pack|
          yield pack.path if block_given?
          session.get(pack.path)
        }
      end

      def served?(path)
        !! local_file_for(path)
      end

      # Returns the local file for a given URI path.
      # Returns nil if a file is not found.
      def local_file_for(request)
        request = request.squeeze('/')
        serve_path, from = served.detect { |path, _| request.start_with?(path) }

        return if !from

        path = File.join(expand_from(from), request.sub(serve_path, ''))
        return if !File.file?(path)

        path
      end

      # Returns the local file for a given URI path. (for dynamic files)
      # Returns nil if a file is not found.
      def dyn_local_file_for(request, from)
        file = request.dup
        extension = File.extname(request)
        # Remove extension
        file.gsub!(/#{extension}$/, "")
        # Remove cache-buster (/js/app.28389 => /js/app)
        file.gsub!(/\.[a-f0-9]{32}$/, "")

        matches = Dir[File.join(expand_from(from), "#{file}.*")]

        # Fix for filenames with dots (can't do this with glob)
        matches = matches.select { |f| f =~ /#{file}\.[^.]+$/ }

        # Sort static file match, weighting exact file extension matches
        # first, then registered Tilt formats
        matches.sort! do |candidate, _|
          cfmt = File.extname(candidate)[1..-1]
          efmt = extension[1..-1]
          (cfmt == efmt or AssetPack.tilt_formats[cfmt] == efmt) ? -1 : 1
        end
        matches.first
      end

      # Returns the files as a hash.
      def files(match=nil)
        return @files unless @reload_files_cache

        # All
        # A buncha tuples
        tuples = @served.map { |prefix, local_path|
          path = File.expand_path(File.join(@app.root, local_path))
          spec = File.join(path, '**', '*')

          Dir[spec].map { |f|
            [ to_uri(f, prefix, path), f ]  unless File.directory?(f)
          }
        }.flatten.compact

        @reload_files_cache = false
        @files = Hash[*tuples]
      end

      # Returns an array of URI paths of those matching given globs.
      #
      #     glob('spec')
      #     glob(['spec1', 'spec2' ...])
      #
      def glob(match)
        paths = Array.new(match) # Force array-ness

        paths.map! do |spec|
          if spec.include?('*')
            files.select do |file, _|
              # Dir#glob like source matching
              File.fnmatch?(spec, file, File::FNM_PATHNAME | File::FNM_DOTMATCH)
            end.sort
          else
            [spec, files[spec]]
          end
        end

        Hash[*paths.flatten]
      end

      # Fetches the contents of a dynamic asset. If `cache_dynamic_assets` is set,
      # check file mtime and potentially return contents from cache instead of re-compiling.
      # Yields to a block to compile & render the asset.
      def fetch_dynamic_asset(path)
        return yield unless cache_dynamic_assets

        mtime = File.mtime(path)
        cached_mtime, contents = @dynamic_asset_cache[path]

        if cached_mtime.nil? || mtime != cached_mtime
          @dynamic_asset_cache[path] = [mtime, yield]
          @dynamic_asset_cache[path][1]
        else
          contents
        end
      end

    private
      # Returns a URI for a given file
      #     path = '/projects/x/app/css'
      #     to_uri('/projects/x/app/css/file.sass', '/styles', path) => '/styles/file.css'
      #
      def to_uri(f, prefix, path)
        fn = (prefix + f.gsub(path, '')).squeeze('/')

        # Switch the extension ('x.sass' => 'x.css')
        file_ext = File.extname(fn).to_s[1..-1]
        out_ext  = AssetPack.tilt_formats[file_ext]

        fn = fn.gsub(/\.#{file_ext}$/, ".#{out_ext}")  if file_ext && out_ext

        fn
      end

      def expand_from(from)
        if from.start_with?('/')
          from
        else
          File.join(app.root, from)
        end
      end
    end
  end
end