module Jammit # The Jammit::Packager resolves the configuration file into lists of real # assets that get merged into individual asset packages. Given the compiled # contents of an asset package, the Packager knows how to cache that package # with the correct timestamps. class Packager # Set force to false to allow packages to only be rebuilt when their source # files have changed since the last time their package was built. attr_accessor :force, :package_names # Creating a new Packager will rebuild the list of assets from the # Jammit.configuration. When assets.yml is being changed on the fly, # create a new Packager. def initialize @force = false @package_names = nil @config = { :css => (Jammit.configuration[:stylesheets] || {}), :js => (Jammit.configuration[:javascripts] || {}) } @packages = { :css => create_packages(@config[:css]), :js => create_packages(@config[:js]) } end # Ask the packager to precache all defined assets, along with their gzip'd # versions. In order to prebuild the MHTML stylesheets, we need to know the # base_url, because IE only supports MHTML with absolute references. # Unless forced, will only rebuild assets whose source files have been # changed since their last package build. def precache_all(output_dir=nil, base_url=nil) output_dir ||= File.join(Jammit.public_root, Jammit.package_path) cacheable(:js, output_dir).each {|p| cache(p, 'js', pack_javascripts(p), output_dir) } cacheable(:css, output_dir).each do |p| cache(p, 'css', pack_stylesheets(p), output_dir) if Jammit.embed_assets cache(p, 'css', pack_stylesheets(p, :datauri), output_dir, :datauri) if Jammit.mhtml_enabled raise MissingConfiguration, "A --base-url option is required in order to generate MHTML." unless base_url mtime = latest_mtime package_for(p, :css)[:paths] asset_url = "#{base_url}#{Jammit.asset_url(p, :css, :mhtml, mtime)}" cache(p, 'css', pack_stylesheets(p, :mhtml, asset_url), output_dir, :mhtml, mtime) end end end end # Caches a single prebuilt asset package and gzips it at the highest # compression level. Ensures that the modification time of both both # variants is identical, for web server caching modules, as well as MHTML. def cache(package, extension, contents, output_dir, suffix=nil, mtime=nil) FileUtils.mkdir_p(output_dir) unless File.exists?(output_dir) raise OutputNotWritable, "Jammit doesn't have permission to write to \"#{output_dir}\"" unless File.writable?(output_dir) mtime ||= latest_mtime package_for(package, extension.to_sym)[:paths] files = [] files << file_name = File.join(output_dir, Jammit.filename(package, extension, suffix)) File.open(file_name, 'wb+') {|f| f.write(contents) } if Jammit.gzip_assets files << zip_name = "#{file_name}.gz" Zlib::GzipWriter.open(zip_name, Zlib::BEST_COMPRESSION) {|f| f.write(contents) } end File.utime(mtime, mtime, *files) end # Get the list of individual assets for a package. def individual_urls(package, extension) package_for(package, extension)[:urls] end def compressor @compressor ||= Compressor.new end # Return the compressed contents of a stylesheet package. def pack_stylesheets(package, variant=nil, asset_url=nil) compressor.compress_css(package_for(package, :css)[:paths], variant, asset_url) end # Return the compressed contents of a javascript package. def pack_javascripts(package) compressor.compress_js(package_for(package, :js)[:paths]) end # Return the compiled contents of a JST package. def pack_templates(package) compressor.compile_jst(package_for(package, :js)[:paths]) end private # Look up a package asset list by name, raising an exception if the # package has gone missing. def package_for(package, extension) pack = @packages[extension] && @packages[extension][package] pack || not_found(package, extension) end # Absolute globs are absolute -- relative globs are relative to ASSET_ROOT. # Print a warning if no files were found that match the glob. def glob_files(glob) absolute = Pathname.new(glob).absolute? paths = Dir[absolute ? glob : File.join(ASSET_ROOT, glob)] #Jammit.warn("No assets match '#{glob}'") if paths.empty? paths end # In Rails, the difference between a path and an asset URL is "public". def path_to_url @path_to_url ||= /\A#{Regexp.escape(ASSET_ROOT)}(\/?#{Regexp.escape(Jammit.public_root.sub(ASSET_ROOT, ''))})?/ end # Get the latest mtime of a list of files (plus the config path). def latest_mtime(paths) paths += [Jammit.config_path] paths.map {|p| File.mtime(p) }.max || Time.now end # Return a list of all of the packages that should be cached. If "force" is # true, this is all of them -- otherwise only the packages that are missing # or whose source files have changed since the last package build. def cacheable(extension, output_dir) names = @packages[extension].keys names = names.select {|n| @package_names.include? n } if @package_names config_mtime = File.mtime(Jammit.config_path) return names if @force return names.select do |name| pack = package_for(name, extension) cached = [Jammit.filename(name, extension)] if extension == :css cached.push Jammit.filename(name, extension, :datauri) if Jammit.embed_assets cached.push Jammit.filename(name, extension, :mhtml) if Jammit.mhtml_enabled end cached.map! {|file| File.join(output_dir, file) } if cached.any? {|file| !File.exists?(file) } true else since = cached.map {|file| File.mtime(file) }.min config_mtime > since || pack[:paths].any? {|src| File.mtime(src) > since } end end end # Compiles the list of assets that goes into each package. Runs an # ordered list of Dir.globs, taking the merged unique result. # If there are JST files in this package we need to add an extra # path for when package_assets is off (e.g. in a dev environment). # This package (e.g. /assets/package-name.jst) will never exist as # an actual file but will be dynamically generated by Jammit on # every request. def create_packages(config) packages = {} return packages if !config config.each do |name, globs| globs ||= [] packages[name] = {} paths = get_file_list(globs.flatten.uniq).uniq packages[name][:paths] = paths if !paths.grep(Jammit.template_extension_matcher).empty? packages[name][:urls] = paths.grep(JS_EXTENSION).map {|path| path.sub(path_to_url, '') } packages[name][:urls] += [Jammit.asset_url(name, Jammit.template_extension)] else packages[name][:urls] = paths.map {|path| path.sub(path_to_url, '') } end puts name end packages end # Raise a PackageNotFound exception for missing packages... def not_found(package, extension) raise PackageNotFound, "assets.yml does not contain a \"#{package}\" #{extension.to_s.upcase} package" end private def get_file_list(globs) paths = [] globs.each do |glob| if String === glob paths.concat glob_files(glob) elsif Hash === glob paths = exclude_paths(paths, glob['exclude']) if glob['exclude'] #puts exclude_paths(paths, glob['exclude']).to_s end end paths end def exclude_paths(paths, globs) globs.each do |glob| excluded_files = glob_files(glob) paths.reject! { |path| excluded_files.include?(path) } end paths end end end