lib/sinatra/assetpack/options.rb
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