# -*- coding: utf-8 -*-
require 'pathname'
require 'digest/md5'
require 'matrix'
require 'set'
module Slinky
# extensions of non-compiled files that can contain build directives
DIRECTIVE_FILES = %w{js css html}
DEPENDS_DIRECTIVE = /^[^\n\w]*(slinky_depends)\((".*"|'.+'|)\)[^\n\w]*$/
REQUIRE_DIRECTIVE = /^[^\n\w]*(slinky_require)\((".*"|'.+'|)\)[^\n\w]*$/
SCRIPTS_DIRECTIVE = /^[^\n\w]*(slinky_scripts)[^\n\w]*$/
STYLES_DIRECTIVE = /^[^\n\w]*(slinky_styles)[^\n\w]*$/
PRODUCT_DIRECTIVE = /^[^\n\w]*(slinky_product)\((".*"|'.+'|)\)[^\n\w]*$/
BUILD_DIRECTIVES = Regexp.union(DEPENDS_DIRECTIVE,
REQUIRE_DIRECTIVE,
SCRIPTS_DIRECTIVE,
STYLES_DIRECTIVE,
PRODUCT_DIRECTIVE)
CSS_URL_MATCHER = /url\(['"]?([^'"\/][^\s)]+\.[a-z]+)(\?\d+)?['"]?\)/
class Manifest
attr_accessor :manifest_dir, :dir, :config
def initialize dir, config, options = {}
@dir = dir
@build_to = if d = options[:build_to]
File.expand_path(d)
else
dir
end
@manifest_dir = ManifestDir.new dir, self, @build_to, self
@devel = (options[:devel].nil?) ? true : options[:devel]
@config = config
@no_minify = options[:no_minify] || config.dont_minify
end
# Returns a list of all files contained in this manifest
#
# @return [ManifestFile] a list of manifest files
def files include_ignores = true
unless @files
@files = []
files_rec @manifest_dir
end
if include_ignores
@files
else
@files.reject{|f| @config.ignore.any?{|p| f.in_tree? p}}
end
end
# Adds a file to the manifest, updating the dependency graph
def add_all_by_path paths
manifest_update paths do |path|
md = find_by_path(File.dirname(path)).first
mf = md.add_file(File.basename(path))
end
end
# Notifies of an update to a file in the manifest
def update_all_by_path paths
manifest_update paths
end
# Removes a file from the manifest
def remove_all_by_path paths
manifest_update paths do |path|
mf = find_by_path(path).first()
if mf
mf.parent.remove_file(mf)
end
end
end
# Finds the file at the given path in the manifest if one exists,
# otherwise nil.
#
# @param String path the path of the file relative to the manifest
#
# @return ManifestFile the manifest file at that path if one exists
def find_by_path path, allow_multiple = false
@manifest_dir.find_by_path path, allow_multiple
end
# Finds all files that match the given pattern. The match rules
# are similar to those for .gitignore and given by
#
# 1. If the pattern ends with a slash, it will only match directories;
# e.g. `foo/` would match a directory `foo/` but not a file `foo`. In
# a file context, matching a directory is equivalent to matching all
# files under that directory, recursively.
# 2. If the pattern does not contain a slash, slinky treats it as a
# relative pathname which can match files in any directory. For
# example, the rule `test.js` will matching `/test.js` and
# `/component/test.js`.
# 3. If the pattern begins with a slash, it will be treated as an
# absolute path starting at the root of the source directory.
# 4. If the pattern does not begin with a slash, but does contain one or
# more slashes, it will be treated as a path relative to any
# directory. For example, `test/*.js` will match `/test/main.js`, and
# /component/test/component.js`, but not `main.js`.
# 5. A single star `*` in a pattern will match any number of characters within a
# single path component. For example, `/test/*.js` will match
# `/test/main_test.js` but not `/test/component/test.js`.
# 6. A double star `**` will match any number of characters including
# path separators. For example `/scripts/**/main.js` will match any
# file named `main.js` under the `/scripts` directory, including
# `/scripts/main.js` and `/scripts/component/main.js`.
def find_by_pattern pattern
# The strategy here is to convert the pattern into an equivalent
# regex and run that against the pathnames of all the files in
# the manifest.
regex_str = Regexp.escape(pattern)
.gsub('\*\*/', ".*")
.gsub('\*\*', ".*")
.gsub('\*', "[^/]*")
if regex_str[0] != '/'
regex_str = '.*/' + regex_str
end
if regex_str[-1] == '/'
regex_str += '.*'
end
regex_str = "^#{regex_str}$"
regex = Regexp.new(regex_str)
files(false).reject{|f|
!regex.match('/' + f.relative_source_path.to_s) &&
!regex.match('/' + f.relative_output_path.to_s)
}
end
# Finds all the matching manifest files for a particular product.
# This does not take into account dependencies.
def files_for_product product
if !p = @config.produce[product]
SlinkyError.raise NoSuchProductError,
"Product '#{product}' has not been configured"
end
type = type_for_product product
if type != ".js" && type != ".css"
SlinkyError.raise InvalidConfigError, "Only .js and .css products are supported"
end
g = dependency_graph.transitive_closure
# Topological indices for each file
indices = {}
dependency_list.each_with_index{|f, i| indices[f] = i}
# Compute the set of excluded files
excludes = Set.new((p["exclude"] || []).map{|p|
find_by_pattern(p)
}.flatten.uniq)
SlinkyError.batch_errors do
# First find the list of files that have been explictly
# included/excluded
p["include"].map{|f|
mfs = find_by_pattern(f)
.map{|mf| [mf] + g[f]}
.flatten
.reject{|f| f.output_path.extname != type}
if mfs.empty?
SlinkyError.raise FileNotFoundError,
"No files matched by include #{f} in product #{product}"
end
mfs.flatten
}.flatten.reject{|f|
excludes.include?(f)
# Then add all the files these require
}.map{|f|
# Find all of the downstream files
# check that we're not excluding any required files
g[f].each{|rf|
if p["exclude"] && r = p["exclude"].find{|ex| rf.matches_path?(ex, true)}
SlinkyError.raise DependencyError,
"File #{f} requires #{rf} which is excluded by exclusion rule #{r}"
end
}
[f] + g[f]
}.flatten.uniq.sort_by{|f|
# Sort by topological order
indices[f]
}
end
end
def files_for_all_products
return @files_for_all_products if @files_for_all_products
SlinkyError.batch_errors do
@files_for_all_products = @config.produce.keys.map{|product|
files_for_product(product)
}.flatten.uniq
end
end
def compress_product product
compressor = compressor_for_product product
post_processor = post_processor_for_product product
s = files_for_product(product).map{|mf|
f = File.open(mf.build_to.to_s, 'rb'){|f| f.read}
post_processor ? (post_processor.call(mf, f)) : f
}.join("\n")
# Make the directory the product is in
FileUtils.mkdir_p("#{@build_to}/#{Pathname.new(product).dirname}")
File.open("#{@build_to}/#{product}", "w+"){|f|
unless @no_minify
f.write(compressor.compress(s))
else
f.write(s)
end
}
end
# These are special cases for simplicity and backwards
# compatability. If no products are defined, we have two default
# products, one which includes are .js files in the repo and one
# that includes all .css files. This method produces an HTML include
# string for all of the .js files.
def scripts_string
product_string ConfigReader::DEFAULT_SCRIPT_PRODUCT
end
# These are special cases for simplicity and backwards
# compatability. If no products are defined, we have two default
# products, one which includes are .js files in the repo and one
# that includes all .css files. This method produces an HTML include
# string for all of the .css files.
def styles_string
product_string ConfigReader::DEFAULT_STYLE_PRODUCT
end
# Produces a string of HTML that includes all of the files for the
# given product.
def product_string product
if @devel
files_for_product(product).map{|f|
html_for_path("/#{f.relative_output_path}")
}.join("\n")
else
html_for_path("#{product}?#{rand(999999999)}")
end
end
# Builds the directed graph representing the dependencies of all
# files in the manifest that contain a slinky_require
# declaration. The graph is represented as a list of pairs
# (required, by), each of which describes an edge.
#
# @return [[ManifestFile, ManifestFile]] the graph
def dependency_graph
return @dependency_graph if @dependency_graph
graph = []
files(false).each{|mf|
mf.dependencies.each{|d|
graph << [d, mf]
}
}
@dependency_graph = Graph.new(files(false), graph)
end
def dependency_list
dependency_graph.dependency_list
end
def build
@manifest_dir.build
unless @devel
@config.produce.keys.each{|product|
compress_product(product)
}
# clean up the files that have been processed
files_for_all_products.each{|mf| FileUtils.rm(mf.build_to, :force => true)}
end
end
# Returns a md5 encompassing the current state of the manifest.
# Any change to the manifest should produce a different hash.
# This can be used to determine if the manifest has changed.
def md5
if @md5
@md5
else
@md5 = Digest::MD5.hexdigest(files.map{|f| [f.source, f.md5]}
.sort.flatten.join(":"))
end
end
private
def files_rec md
@files += md.files
md.children.each do |c|
files_rec c
end
end
def compressor_for_product product
case type_for_product(product)
when ".js"
YUI::JavaScriptCompressor.new(:munge => false)
when ".css"
YUI::CssCompressor.new()
end
end
def post_processor_for_product product
case type_for_product(product)
when ".css"
lambda{|s, css| css.gsub(CSS_URL_MATCHER){|url|
p = s.relative_output_path.dirname.to_s + "/#{$1}"
"url('/#{p}')"
}}
end
end
def invalidate_cache
@files = nil
@dependency_graph = nil
@md5 = nil
@files_for_all_products = nil
end
def html_for_path path
ext = path.split("?").first.split(".").last
case ext
when "css"
%Q||
when "js"
%Q||
else
raise InvalidConfigError.new("Unsupported file extension #{ext}")
end
end
def type_for_product product
"." + product.split(".")[-1]
end
def manifest_update paths
paths.each{|path|
if path[0] == '/'
path = Pathname.new(path).relative_path_from(Pathname.new(@dir).expand_path).to_s
end
yield path if block_given?
}
invalidate_cache
files.each{|f|
if f.directives.include?(:slinky_scripts) || f.directives.include?(:slinky_styles)
f.invalidate
f.process
end
}
end
end
class ManifestDir
attr_accessor :dir, :parent, :files, :children
def initialize dir, parent, build_dir, manifest
@dir = dir
@parent = parent
@files = []
@children = []
@build_dir = Pathname.new(build_dir)
@manifest = manifest
Dir.glob("#{dir}/*").each do |path|
# skip the build dir
next if Pathname.new(File.expand_path(path)) == Pathname.new(build_dir)
if File.directory? path
add_child(path)
else
add_file(path)
end
end
end
# Finds the file at the given path in the directory if one exists,
# otherwise nil.
#
# @param String path the path of the file relative to the directory
# @param Boolean allow_multiple if enabled, can return multiple paths
# according to glob rules
#
# @return [ManifestFile] the manifest file at that path if one exists
def find_by_path path, allow_multiple = false
if path[0] == '/'
# refer absolute paths to the manifest
return @manifest.find_by_path(path[1..-1], allow_multiple)
end
components = path.to_s.split(File::SEPARATOR).reject{|x| x == ""}
case components.size
when 0
[self]
when 1
path = [@dir, components[0]].join(File::SEPARATOR)
if (File.directory?(path) rescue false)
c = @children.find{|d|
Pathname.new(d.dir).cleanpath == Pathname.new(path).cleanpath
}
unless c
c = add_child(path)
end
[c]
else
@files.find_all{|f| f.matches? components[0], allow_multiple}
end
else
if components[0] == ".."
@parent.find_by_path components[1..-1].join(File::SEPARATOR)
else
child = @children.find{|d|
Pathname.new(d.dir).basename.to_s == components[0]
}
if child
child.find_by_path(components[1..-1].join(File::SEPARATOR),
allow_multiple)
else
[]
end
end
end
end
# Adds a child directory
def add_child path
if File.directory? path
build_dir = (@build_dir + File.basename(path)).cleanpath
md = ManifestDir.new(path, self, build_dir, @manifest)
@children << md
md
end
end
# Adds a file on the filesystem to the manifest
#
# @param String path The path of the file
def add_file path
file = File.basename(path)
full_path = Pathname.new(@dir).join(file).to_s
if File.exists?(full_path) && !file.start_with?(".")
mf = ManifestFile.new(full_path, @build_dir, @manifest, self)
# we don't want two files with the same source
extant_file = @files.find{|f| f.source == mf.source}
if extant_file
@files.delete(extant_file)
end
@files << mf
mf
end
end
# Removes a file from the manifest
#
# @param ManifestFile mf The file to be deleted
def remove_file mf
@files.delete(mf)
end
def build
unless File.directory?(@build_dir.to_s)
FileUtils.mkdir(@build_dir.to_s)
end
if (@files + @children).map {|m| m.build}.any?
@build_dir
else
FileUtils.rmdir(@build_dir.to_s)
nil
end
end
def to_s
""
end
end
class ManifestFile
attr_accessor :source, :build_path
attr_reader :last_built, :directives, :parent, :manifest, :updated
def initialize source, build_path, manifest, parent = nil, options = {:devel => false}
@parent = parent
@source = source
@last_built = Time.at(0)
@cfile = Compilers.cfile_for_file(@source)
@directives = find_directives
@build_path = build_path
@manifest = manifest
@devel = true if options[:devel]
end
def invalidate
@last_built = Time.at(0)
@last_md5 = nil
end
# Gets the list of manifest files that this one depends on
# according to its directive list and the dependencies config
# option.
#
# Throws a FileNotFoundError if a dependency doesn't exist in the
# tree.
def dependencies
SlinkyError.batch_errors do
(@directives[:slinky_require].to_a +
@manifest.config.dependencies["/" + relative_source_path.to_s].to_a).map{|rf|
required = parent.find_by_path(rf, true).flatten
if required.empty?
error = "Could not find file #{rf} required by /#{relative_source_path}"
SlinkyError.raise FileNotFoundError, error
end
required
}.flatten
end
end
# Predicate which determines whether the supplied name is the same
# as the file's name, taking into account compiled file
# extensions. For example, if mf refers to "/tmp/test/hello.sass",
# `mf.matches? "hello.sass"` and `mf.matches? "hello.css"` should
# both return true.
#
# @param String a filename
# @param Bool match_glob if true, matches according to glob rules
# @return Bool True if the filename matches, false otherwise
def matches? s, match_glob = false
name = Pathname.new(@source).basename.to_s
output = output_path.basename.to_s
# check for stars that are not escaped
a = [""]
last = ""
s.each_char {|c|
if c != "*" || last == "\\"
a[-1] << c
else
a << ""
end
last = c
}
if match_glob && a.size > 1
r2 = a.reduce{|a, x| /#{a}.*#{x}/}
name.match(r2) || output.match(r2)
else
name == s || output == s
end
end
# Predicate which determines whether the file matches (see
# `ManifestFile#matches?`) the full path relative to the manifest
# root.
def matches_path? s, match_glob = false
p = Pathname.new(s)
dir = Pathname.new("/" + relative_source_path.to_s).dirname
matches?(p.basename.to_s, match_glob) &&
dir == p.dirname
end
# Predicate which determines whether the file is the supplied path
# or lies on supplied tree
def in_tree? path
full_path = @manifest.dir + "/" + path
abs_path = Pathname.new(full_path).expand_path.to_s
dir = Pathname.new(@source).dirname.expand_path.to_s
dir.start_with?(abs_path) || abs_path == @source
end
# Returns the path to which this file should be output. This is
# equal to the source path unless the file needs to be compiled,
# in which case the extension returned is the output extension
#
# @return Pathname the output path
def output_path
if @cfile
ext = /\.[^.]*$/
Pathname.new(@source.gsub(ext, ".#{@cfile.output_ext}"))
else
Pathname.new(@source)
end
end
# returns the source path relative to the manifest directory
def relative_source_path
Pathname.new(@source).relative_path_from(Pathname.new(@manifest.dir))
end
# Returns the output path relative to the manifest directory
def relative_output_path
output_path.relative_path_from(Pathname.new(@manifest.dir))
end
# Looks through the file for directives
# @return {Symbol => [String]} the directives in the file
def find_directives
_, _, ext = @source.match(EXTENSION_REGEX).to_a
directives = {}
# check if this file might include directives
if @cfile || DIRECTIVE_FILES.include?(ext)
# make sure the file isn't too big to scan
stat = File::Stat.new(@source)
if stat.size < 1024*1024
File.open(@source) {|f|
matches = f.read.scan(BUILD_DIRECTIVES).to_a
matches.each{|slice|
key, value = slice.compact
directives[key.to_sym] ||= []
directives[key.to_sym] << value[1..-2] if value
}
} rescue nil
end
end
@directives = directives
end
# If there are any build directives for this file, the file is
# read and the directives are handled appropriately and a new file
# is written to a temp location.
#
# @return String the path of the de-directivefied file
def handle_directives path, to = nil
if path && @directives.size > 0
out = File.read(path)
out.gsub!(DEPENDS_DIRECTIVE, "")
out.gsub!(REQUIRE_DIRECTIVE, "")
out.gsub!(SCRIPTS_DIRECTIVE){ @manifest.scripts_string }
out.gsub!(STYLES_DIRECTIVE){ @manifest.styles_string }
out.gsub!(PRODUCT_DIRECTIVE){
@manifest.product_string($2[1..-2])
}
to = to || Tempfile.new("slinky").path + ".cache"
File.open(to, "w+"){|f|
f.write(out)
}
to
else
path
end
end
# Takes a path and compiles the file if necessary.
# @return Pathname the path of the compiled file, or the original
# path if compiling is not necessary
def compile path, to = nil
if @cfile
cfile = @cfile.clone
cfile.source = path
cfile.print_name = @source
cfile.output_path = to if to
cfile.file do |cpath, _, _, _|
path = cpath
end
end
path ? Pathname.new(path) : nil
end
# Gets the md5 hash of the source file
def md5
Digest::MD5.hexdigest(File.read(@source)) rescue nil
end
# Gets manifest file ready for serving or building by handling the
# directives and compiling the file if neccesary.
# @param String path to which the file should be compiled
#
# @return String the path of the processed file, ready for serving
def process to = nil, should_compile = true
return if @processing # prevent infinite recursion
start_time = Time.now
hash = md5
if hash != @last_md5
find_directives
end
SlinkyError.batch_errors do
depends = @directives[:slinky_depends].map{|f|
p = parent.find_by_path(f, true)
unless p.size > 0
SlinkyError.raise DependencyError,
"File #{f} dependedon by #{@source} not found"
end
p
}.flatten.compact if @directives[:slinky_depends]
depends ||= []
@processing = true
# process each file on which we're dependent, watching out for
# infinite loops
depends.each{|f| f.process }
@processing = false
# get hash of source file
if @last_path && hash == @last_md5 && depends.all?{|f| f.updated < start_time}
@last_path
else
@last_md5 = hash
@updated = Time.now
# mangle file appropriately
f = should_compile ? (compile @source) : @source
@last_path = handle_directives(f, to)
end
end
end
# Path to which the file will be built
def build_to
Pathname.new(@build_path) + output_path.basename
end
# Builds the file by handling and compiling it and then copying it
# to the build path
def build
return nil unless should_build
if !File.exists? @build_path
FileUtils.mkdir_p(@build_path)
end
to = build_to
path = process to
if path != to
FileUtils.cp(path.to_s, to.to_s)
@last_built = Time.now
end
to
end
def should_build
@manifest.files_for_all_products.include?(self) || ![".js", ".css"].include?(output_path.extname)
end
def inspect
to_s
end
def to_s
""
end
end
end