class Roda
module RodaPlugins
# The assets plugin adds support for rendering your CSS and javascript
# asset files on the fly in development, and compiling them
# to a single, compressed file in production.
#
# This uses the render plugin for rendering the assets, and the render
# plugin uses tilt internally, so you can use any template engine
# supported by tilt for you assets. Tilt ships with support for
# the following asset template engines, assuming the necessary libaries
# are installed:
#
# css :: Less, Sass, Scss
# js :: CoffeeScript
#
# == Usage
#
# When loading the plugin, use the :css and :js options
# to set the source file(s) to use for CSS and javascript assets:
#
# plugin :assets, :css => 'some_file.scss', :js => 'some_file.coffee'
#
# This will look for the following files:
#
# assets/css/some_file.scss
# assets/js/some_file.coffee
#
# If you want to change the paths where asset files are stored, see the
# Options section below.
#
# === Serving
#
# In your routes, call the r.assets method to add a route to your assets,
# which will make your app serve the rendered assets:
#
# route do |r|
# r.assets
# end
#
# You should generally call +r.assets+ inside the route block itself, and not
# under any branches of the routing tree.
#
# === Views
#
# In your layout view, use the assets method to add links to your CSS and
# javascript assets:
#
# <%= assets(:css) %>
# <%= assets(:js) %>
#
# You can add attributes to the tags by using an options hash:
#
# <%= assets(:css, :media => 'print') %>
#
# == Asset Groups
#
# The asset plugin supports groups for the cases where you have different
# css/js files for your front end and back end. To use asset groups, you
# pass a hash for the :css and/or :js options:
#
# plugin :assets, :css => {:frontend => 'some_frontend_file.scss',
# :backend => 'some_backend_file.scss'}
#
# This expects the following directory structure for your assets:
#
# assets/css/frontend/some_frontend_file.scss
# assets/css/backend/some_backend_file.scss
#
# If you want do not want to force that directory structure when using
# asset groups, you can use the :group_subdirs => false option.
#
# In your view code use an array argument in your call to assets:
#
# <%= assets([:css, :frontend]) %>
#
# === Nesting
#
# Asset groups also supporting nesting, though that should only be needed
# in fairly large applications. You can use a nested hash when loading
# the plugin:
#
# plugin :assets,
# :css => {:frontend => {:dashboard => 'some_frontend_file.scss'}}
#
# and an extra entry per nesting level when creating the tags.
#
# <%= assets([:css, :frontend, :dashboard]) %>
#
# == Caching
#
# The assets plugin uses the caching plugin internally, and will set the
# Last-Modified header to the modified timestamp of the asset source file
# when rendering the asset.
#
# If you have assets that include other asset files, such as using @import
# in a sass file, you need to specify the dependencies for your assets so
# that the assets plugin will correctly pick up changes. You can do this
# using the :dependencies option to the plugin, which takes a hash where
# the keys are paths to asset files, and values are arrays of paths to
# dependencies of those asset files:
#
# app.plugin :assets,
# :dependencies=>{'assets/css/bootstrap.scss'=>Dir['assets/css/bootstrap/' '**/*.scss']}
#
# == Asset Compilation
#
# In production, you are generally going to want to compile your assets
# into a single file, with you can do by calling compile_assets after
# loading the plugin:
#
# plugin :assets, :css => 'some_file.scss', :js => 'some_file.coffee'
# compile_assets
#
# After calling compile_assets, calls to assets in your views will default
# to a using a single link each to your CSS and javascript compiled asset
# files. By default the compiled files are written to the public directory,
# so that they can be served by the webserver.
#
# === Asset Compression
#
# If you have the yuicompressor gem installed and working, it will be used
# automatically to compress your javascript and css assets. Otherwise,
# the assets will just be concatenated together and not compressed during
# compilation.
#
# === With Asset Groups
#
# When using asset groups, a separate compiled file will be produced per
# asset group.
#
# === Unique Asset Names
#
# When compiling assets, a unique name is given to each asset file, using the
# a SHA1 hash of the content of the file. This is done so that clients do
# not attempt to use cached versions of the assets if the asset has changed.
#
# === Serving
#
# If you call +r.assets+ when compiling assets, will serve the compiled asset
# files. However, it is recommended to have the main webserver (e.g. nginx)
# serve the compiled files, instead of relying on the application.
#
# Assuming you are using compiled assets in production mode that are served
# by the webserver, you can remove the serving of them by the application:
#
# route do |r|
# r.assets unless ENV['RACK_ENV'] == 'production'
# end
#
# If you do have the application serve the compiled assets, it will use the
# Last-Modified header to make sure that clients do not redownload compiled
# assets that haven't changed.
#
# === Asset Precompilation
#
# If you want to precompile your assets, so they do not need to be compiled
# every time you boot the application, you can provide a :precompiled option
# when loading the plugin. The value of this option should be the filename
# where the compiled asset metadata is stored.
#
# If the compiled assset metadata file does not exist when the assets plugin
# is loaded, the plugin will run in non-compiled mode. However, when you call
# compile_assets, it will write the compiled asset metadata file after
# compiling the assets.
#
# If the compiled asset metadata file already exists when the assets plugin
# is loaded, the plugin will read the file to get the compiled asset metadata,
# and it will run in compiled mode, assuming that the compiled asset files
# already exist.
#
# ==== On Heroku
#
# Heroku supports precompiling the assets when using Roda. You just need to
# add an assets:precompile task, similar to this:
#
# namespace :assets do
# desc "Precompile the assets"
# task :precompile do
# require './app'
# App.compile_assets
# end
# end
#
# == Plugin Options
#
# :add_suffix :: Whether to append a .css or .js extension to asset routes in non-compiled mode
# (default: false)
# :compiled_css_dir :: Directory name in which to store the compiled css file,
# inside :compiled_path (default: nil)
# :compiled_css_route :: Route under :prefix for compiled css assets (default: :compiled_css_dir)
# :compiled_js_dir :: Directory name in which to store the compiled javascript file,
# inside :compiled_path (default: nil)
# :compiled_js_route :: Route under :prefix for compiled javscript assets (default: :compiled_js_dir)
# :compiled_name :: Compiled file name prefix (default: 'app')
# :compiled_path:: Path inside public folder in which compiled files are stored (default: :prefix)
# :concat_only :: Whether to just concatenate instead of concatentating
# and compressing files (default: false)
# :css_dir :: Directory name containing your css source, inside :path (default: 'css')
# :css_headers :: A hash of additional headers for your rendered css files
# :css_opts :: Options to pass to the render plugin when rendering css assets
# :css_route :: Route under :prefix for css assets (default: :css_dir)
# :dependencies :: A hash of dependencies for your asset files. Keys should be paths to asset files,
# values should be arrays of paths your asset files depends on. This is used to
# detect changes in your asset files.
# :group_subdirs :: Whether a hash used in :css and :js options requires the assets for the
# related group are contained in a subdirectory with the same name (default: true)
# :headers :: A hash of additional headers for both js and css rendered files
# :js_dir :: Directory name containing your javascript source, inside :path (default: 'js')
# :js_headers :: A hash of additional headers for your rendered javascript files
# :js_opts :: Options to pass to the render plugin when rendering javascript assets
# :js_route :: Route under :prefix for javascript assets (default: :js_dir)
# :path :: Path to your asset source directory (default: 'assets')
# :prefix :: Prefix for assets path in your URL/routes (default: 'assets')
# :precompiled :: Path to the compiled asset metadata file. If the file exists, will use compiled
# mode using the metadata in the file. If the file does not exist, will use
# non-compiled mode, but will write the metadata to the file if compile_assets is called.
# :public :: Path to your public folder, in which compiled files are placed (default: 'public')
module Assets
DEFAULTS = {
:compiled_name => 'app'.freeze,
:js_dir => 'js'.freeze,
:css_dir => 'css'.freeze,
:path => 'assets'.freeze,
:prefix => 'assets'.freeze,
:public => 'public'.freeze,
:concat_only => false,
:compiled => false,
:add_suffix => false,
:group_subdirs => true,
:compiled_css_dir => nil,
:compiled_js_dir => nil,
}.freeze
JS_END = "\">".freeze
CSS_END = "\" />".freeze
SPACE = ' '.freeze
DOT = '.'.freeze
SLASH = '/'.freeze
NEWLINE = "\n".freeze
EMPTY_STRING = ''.freeze
JS_SUFFIX = '.js'.freeze
CSS_SUFFIX = '.css'.freeze
# Load the render and caching plugins plugins, since the assets plugin
# depends on them.
def self.load_dependencies(app, _opts = {})
app.plugin :render
app.plugin :caching
end
# Setup the options for the plugin. See the Assets module RDoc
# for a description of the supported options.
def self.configure(app, opts = {})
if app.assets_opts
prev_opts = app.assets_opts[:orig_opts]
orig_opts = app.assets_opts[:orig_opts].merge(opts)
[:headers, :css_headers, :js_headers, :css_opts, :js_opts, :dependencies].each do |s|
if prev_opts[s]
if opts[s]
orig_opts[s] = prev_opts[s].merge(opts[s])
else
orig_opts[s] = prev_opts[s].dup
end
end
end
app.opts[:assets] = orig_opts.dup
app.opts[:assets][:orig_opts] = orig_opts
else
app.opts[:assets] = opts.dup
app.opts[:assets][:orig_opts] = opts
end
opts = app.opts[:assets]
# Combine multiple values into a path, ignoring trailing slashes
j = lambda do |*v|
opts.values_at(*v).
reject{|s| s.to_s.empty?}.
map{|s| s.chomp('/')}.
join('/').freeze
end
# Same as j, but add a trailing slash if not empty
sj = lambda do |*v|
s = j.call(*v)
s.empty? ? s : (s + '/').freeze
end
if opts[:precompiled] && !opts[:compiled] && ::File.exist?(opts[:precompiled])
require 'json'
opts[:compiled] = ::JSON.parse(::File.read(opts[:precompiled]))
end
DEFAULTS.each do |k, v|
opts[k] = v unless opts.has_key?(k)
end
[
[:compiled_path, :prefix],
[:js_route, :js_dir],
[:css_route, :css_dir],
[:compiled_js_route, :compiled_js_dir],
[:compiled_css_route, :compiled_css_dir]
].each do |k, v|
opts[k] = opts[v] unless opts.has_key?(k)
end
[:css_headers, :js_headers, :css_opts, :js_opts, :dependencies].each do |s|
opts[s] ||= {}
end
if headers = opts[:headers]
opts[:css_headers] = headers.merge(opts[:css_headers])
opts[:js_headers] = headers.merge(opts[:js_headers])
end
opts[:css_headers]['Content-Type'] ||= "text/css; charset=UTF-8".freeze
opts[:js_headers]['Content-Type'] ||= "application/javascript; charset=UTF-8".freeze
[:css_headers, :js_headers, :css_opts, :js_opts, :dependencies].each do |s|
opts[s].freeze
end
[:headers, :css, :js].each do |s|
opts[s].freeze if opts[s]
end
# Used for reading/writing files
opts[:js_path] = sj.call(:path, :js_dir)
opts[:css_path] = sj.call(:path, :css_dir)
opts[:compiled_js_path] = j.call(:public, :compiled_path, :compiled_js_dir, :compiled_name)
opts[:compiled_css_path] = j.call(:public, :compiled_path, :compiled_css_dir, :compiled_name)
# Used for URLs/routes
opts[:js_prefix] = sj.call(:prefix, :js_route)
opts[:css_prefix] = sj.call(:prefix, :css_route)
opts[:compiled_js_prefix] = j.call(:prefix, :compiled_js_route, :compiled_name)
opts[:compiled_css_prefix] = j.call(:prefix, :compiled_css_route, :compiled_name)
opts[:js_suffix] = opts[:add_suffix] ? JS_SUFFIX : EMPTY_STRING
opts[:css_suffix] = opts[:add_suffix] ? CSS_SUFFIX : EMPTY_STRING
opts.freeze
end
module ClassMethods
# Return the assets options for this class.
def assets_opts
opts[:assets]
end
# Compile options for the given asset type. If no asset_type
# is given, compile both the :css and :js asset types. You
# can specify an array of types (e.g. [:css, :frontend]) to
# compile assets for the given asset group.
def compile_assets(type=nil)
require 'fileutils'
unless assets_opts[:compiled]
opts[:assets] = assets_opts.merge(:compiled => {})
end
if type == nil
_compile_assets(:css)
_compile_assets(:js)
else
_compile_assets(type)
end
if assets_opts[:precompiled]
require 'json'
::FileUtils.mkdir_p(File.dirname(assets_opts[:precompiled]))
::File.open(assets_opts[:precompiled], 'wb'){|f| f.write(assets_opts[:compiled].to_json)}
end
assets_opts[:compiled]
end
private
# Internals of compile_assets, handling recursive calls for loading
# all asset groups under the given type.
def _compile_assets(type)
type, *dirs = type if type.is_a?(Array)
dirs ||= []
files = assets_opts[type]
dirs.each{|d| files = files[d]}
case files
when Hash
files.each_key{|dir| _compile_assets([type] + dirs + [dir])}
else
files = Array(files)
compile_assets_files(files, type, dirs) unless files.empty?
end
end
# Compile each array of files for the given type into a single
# file. Dirs should be an array of asset group names, if these
# are files in an asset group.
def compile_assets_files(files, type, dirs)
dirs = nil if dirs && dirs.empty?
o = assets_opts
app = new
content = files.map do |file|
file = "#{dirs.join('/')}/#{file}" if dirs && o[:group_subdirs]
file = "#{o[:"#{type}_path"]}#{file}"
app.read_asset_file(file, type)
end.join
unless o[:concat_only]
content = compress_asset(content, type)
end
suffix = ".#{dirs.join('.')}" if dirs
key = "#{type}#{suffix}"
unique_id = o[:compiled][key] = asset_digest(content)
path = "#{o[:"compiled_#{type}_path"]}#{suffix}.#{unique_id}.#{type}"
::FileUtils.mkdir_p(File.dirname(path))
::File.open(path, 'wb'){|f| f.write(content)}
nil
end
# Compress the given content for the given type using yuicompressor,
# but handle cases where yuicompressor isn't installed or can't find
# a java runtime. This method can be overridden by the application
# to use a different compressor.
def compress_asset(content, type)
require 'yuicompressor'
# :nocov:
content = YUICompressor.send("compress_#{type}", content, :munge => true)
# :nocov:
rescue LoadError, Errno::ENOENT
# yuicompressor or java not available, just use concatenated, uncompressed output
content
end
# Return a unique id for the given content. By default, uses the
# SHA1 hash of the content. This method can be overridden to use
# a different digest type or to return a static string if you don't
# want to use a unique value.
def asset_digest(content)
require 'digest/sha1'
Digest::SHA1.hexdigest(content)
end
end
module InstanceMethods
# Return a string containing html tags for the given asset type.
# This will use a script tag for the :js type and a link tag for
# the :css type.
#
# To return the tags for a specific asset group, use an array for
# the type, such as [:css, :frontend].
#
# When the assets are not compiled, this will result in a separate
# tag for each asset file. When the assets are compiled, this will
# result in a single tag to the compiled asset file.
def assets(type, attrs = nil)
o = self.class.assets_opts
type, *dirs = type if type.is_a?(Array)
stype = type.to_s
attrs = if attrs
ru = Rack::Utils
attrs.map{|k,v| "#{k}=\"#{ru.escape_html(v.to_s)}\""}.join(SPACE)
else
EMPTY_STRING
end
if type == :js
tag_start = "