cjohansen-juicer 0.2.0 → 0.2.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. data/History.txt +17 -5
  2. data/Manifest.txt +33 -15
  3. data/Rakefile +22 -1
  4. data/Readme.rdoc +68 -32
  5. data/bin/juicer +1 -0
  6. data/lib/juicer.rb +26 -1
  7. data/lib/juicer/binary.rb +173 -0
  8. data/lib/juicer/cache_buster.rb +45 -0
  9. data/lib/juicer/chainable.rb +1 -0
  10. data/lib/juicer/cli.rb +13 -8
  11. data/lib/juicer/command/install.rb +59 -0
  12. data/lib/juicer/command/list.rb +50 -0
  13. data/lib/juicer/command/merge.rb +130 -31
  14. data/lib/juicer/command/util.rb +32 -0
  15. data/lib/juicer/command/verify.rb +60 -0
  16. data/lib/juicer/core.rb +61 -0
  17. data/lib/juicer/css_cache_buster.rb +106 -0
  18. data/lib/juicer/install/base.rb +186 -0
  19. data/lib/juicer/install/jslint_installer.rb +51 -0
  20. data/lib/juicer/install/rhino_installer.rb +52 -0
  21. data/lib/juicer/install/yui_compressor_installer.rb +66 -0
  22. data/lib/juicer/jslint.rb +90 -0
  23. data/lib/juicer/merger/base.rb +74 -72
  24. data/lib/juicer/merger/dependency_resolver.rb +34 -16
  25. data/lib/juicer/merger/stylesheet_merger.rb +71 -1
  26. data/lib/juicer/minifyer/yui_compressor.rb +20 -43
  27. data/tasks/test/setup.rake +35 -0
  28. data/test/juicer/command/test_install.rb +53 -0
  29. data/test/juicer/command/test_list.rb +69 -0
  30. data/test/juicer/command/test_merge.rb +160 -0
  31. data/test/juicer/command/test_util.rb +54 -0
  32. data/test/juicer/command/test_verify.rb +33 -0
  33. data/test/juicer/install/test_installer_base.rb +195 -0
  34. data/test/juicer/install/test_jslint_installer.rb +54 -0
  35. data/test/juicer/install/test_rhino_installer.rb +57 -0
  36. data/test/juicer/install/test_yui_compressor_installer.rb +56 -0
  37. data/test/juicer/merger/test_base.rb +2 -3
  38. data/test/juicer/merger/test_css_dependency_resolver.rb +8 -4
  39. data/test/juicer/merger/test_javascript_dependency_resolver.rb +6 -7
  40. data/test/juicer/merger/test_javascript_merger.rb +1 -2
  41. data/test/juicer/merger/test_stylesheet_merger.rb +118 -2
  42. data/test/juicer/minifyer/test_yui_compressor.rb +109 -29
  43. data/test/juicer/test_cache_buster.rb +58 -0
  44. data/test/juicer/test_chainable.rb +7 -0
  45. data/test/juicer/test_core.rb +47 -0
  46. data/test/juicer/test_css_cache_buster.rb +91 -0
  47. data/test/juicer/test_jslint.rb +33 -0
  48. data/test/test_helper.rb +65 -196
  49. metadata +77 -26
  50. data/.gitignore +0 -2
  51. data/juicer.gemspec +0 -38
  52. data/lib/juicer/minifyer/compressor.rb +0 -125
  53. data/test/juicer/minifyer/test_compressor.rb +0 -36
@@ -0,0 +1,45 @@
1
+ module Juicer
2
+ #
3
+ # Tool that assists in creating filenames that update everytime the file
4
+ # contents change. There's two ways of generating filenames, soft and hard.
5
+ # The point of all this is to facilitate configuring web servers to send
6
+ # static assets with a far future expires header - improving end user
7
+ # performance through caching.
8
+ #
9
+ # Soft cache busters require no web server configuration, but will not work
10
+ # as intended with older default configurations for popular proxy server
11
+ # Squid. The soft busters use query parameters to create unique file names,
12
+ # and these may not force an update in some cases. The soft cache busters
13
+ # transforms /images/logo.png to /images/logo.png?cb=1232923789
14
+ #
15
+ # Hard cache busters change the file name itself, and thus requires either
16
+ # the web server to (internally) rewrite requests for these files to the
17
+ # original ones, or the file names to actually change. Hard cache busters
18
+ # transforms /images/logo.png to /images/logo-1232923789.png
19
+ #
20
+ module CacheBuster
21
+ #
22
+ # Creates a unique file name for every revision to the files contents.
23
+ # Default parameter name for soft cache busters is cb (ie ?cb=<timestamp>)
24
+ # while default parameter names for hard cache busters is none (ie
25
+ # file-<timestamp>.png).
26
+ #
27
+ def self.path(file, type = :soft, param = :undef)
28
+ param = (type == :soft ? "jcb" : nil) if param == :undef
29
+ f = File.new(file.split("?").first)
30
+ mtime = f.mtime.to_i
31
+ f.close
32
+
33
+ if type == :soft
34
+ param = "#{param}".length == 0 ? "" : "#{param}="
35
+ file = file.sub(/#{param}\d+/, "").sub(/(\?|\&)$/, "")
36
+ "#{file}#{file.index('?') ? '&' : '?'}#{param}#{mtime}"
37
+ else
38
+ parts = file.split(".")
39
+ suffix = parts.pop
40
+ file = parts.join.sub(/-#{param}\d+/, "")
41
+ "#{parts.join('.')}-#{param}#{mtime}.#{suffix}"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -63,6 +63,7 @@ module Juicer
63
63
  #
64
64
  def next_in_chain=(next_obj)
65
65
  @_next_in_chain = next_obj
66
+ next_obj || self
66
67
  end
67
68
 
68
69
  alias_method :set_next, :next_in_chain=
data/lib/juicer/cli.rb CHANGED
@@ -1,12 +1,13 @@
1
- require 'rubygems'
2
- require 'cmdparse'
1
+ require "cmdparse"
3
2
 
4
3
  # Command line interpreter for Juicer
5
4
  #
6
5
  module Juicer
7
6
  class Cli
7
+
8
8
  def initialize
9
- $verbose = false
9
+ @log = Juicer::LOGGER
10
+ @log.level = Logger::INFO
10
11
  end
11
12
 
12
13
  # Set up command parser and parse arguments
@@ -16,13 +17,17 @@ module Juicer
16
17
  @cmd.program_name = "juicer"
17
18
  @cmd.program_version = Juicer.version.split(".")
18
19
 
19
- # @cmd.options = CmdParse::OptionParserWrapper.new do |opt|
20
- # opt.separator "Global options:"
21
- # opt.on("--verbose", "Be verbose when outputting info") {|t| $verbose = true }
22
- # end
20
+ @cmd.options = CmdParse::OptionParserWrapper.new do |opt|
21
+ opt.separator "Global options:"
22
+ opt.on("-v", "--verbose", "Be verbose when outputting info") { |t| @log.level = Logger::DEBUG }
23
+ opt.on("-q", "--quiet", "Only log warnings and errors") { |t| @log.level = Logger::WARN }
24
+ end
23
25
 
24
26
  add_commands
25
27
  @cmd.parse(arguments)
28
+ @log.close
29
+ rescue SystemExit
30
+ exit
26
31
  end
27
32
 
28
33
  # Run CLI
@@ -43,7 +48,7 @@ module Juicer
43
48
  if Juicer.const_defined?("Command")
44
49
  Juicer::Command.constants.each do |const|
45
50
  const = Juicer::Command.const_get(const)
46
- @cmd.add_command(const.new) if const.kind_of?(Class)
51
+ @cmd.add_command(const.new(@log)) if const.kind_of?(Class)
47
52
  end
48
53
  end
49
54
  end
@@ -0,0 +1,59 @@
1
+ require File.join(File.dirname(__FILE__), "util")
2
+ require "cmdparse"
3
+ require "pathname"
4
+
5
+ module Juicer
6
+ module Command
7
+ # Installs a third party library so Juicer can use it.
8
+ #
9
+ class Install < CmdParse::Command
10
+ include Juicer::Command::Util
11
+
12
+ # Initializes command
13
+ #
14
+ def initialize(io = nil)
15
+ super('install', false, true)
16
+ @io = io || Logger.new(STDOUT)
17
+ @version = nil
18
+ @path = Juicer.home
19
+ self.short_desc = "Install a third party library"
20
+ self.description = <<-EOF
21
+ Installs a third party used by Juicer. Downloads necessary binaries and licenses
22
+ into Juicer installation directory, usually ~/.juicer
23
+ EOF
24
+
25
+ self.options = CmdParse::OptionParserWrapper.new do |opt|
26
+ opt.on('-v', '--version [VERSION]', 'Specify version of library to install') { |version| @version = version }
27
+ end
28
+ end
29
+
30
+ # Execute command
31
+ #
32
+ def execute(args)
33
+ if args.length == 0
34
+ raise ArgumentError.new('Please provide a library to install')
35
+ end
36
+
37
+ args.each do |lib|
38
+ installer = Juicer::Install.get(lib).new(@path)
39
+ path = File.join(installer.install_dir, installer.path)
40
+ version = version(installer)
41
+
42
+ if installer.installed?(version)
43
+ @io.info "#{installer.name} #{version} is already installed in #{path}"
44
+ break
45
+ end
46
+
47
+ installer.install(version)
48
+ @io.info "Successfully installed #{lib.camel_case} #{version} in #{path}" if installer.installed?(version)
49
+ end
50
+ end
51
+
52
+ # Returns which version to install
53
+ #
54
+ def version(installer)
55
+ @version ||= installer.latest
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,50 @@
1
+ require File.join(File.dirname(__FILE__), "util")
2
+ require "cmdparse"
3
+ require "pathname"
4
+
5
+ module Juicer
6
+ module Command
7
+ # Displays a list of files that make up the dependency chain for the input
8
+ # files/patterns.
9
+ #
10
+ class List < CmdParse::Command
11
+ include Juicer::Command::Util
12
+
13
+ # Initializes command
14
+ #
15
+ def initialize(io = STDOUT)
16
+ super('list', false, true)
17
+ @io = io
18
+ self.short_desc = "Lists all dependencies for all input files/patterns"
19
+ self.description = <<-EOF
20
+ Dependencies are looked up recursively. The dependency chain reveals which files
21
+ will be joined by juicer merge.
22
+
23
+ Input parameters may be:
24
+ * Single file, ie $ juicer list myfile.css
25
+ * Single glob pattern, ie $ juicer list **/*.css
26
+ * Multiple mixed arguments, ie $ juicer list **/*.js **/*.css
27
+ EOF
28
+ end
29
+
30
+ # Execute command
31
+ #
32
+ def execute(args)
33
+ if args.length == 0
34
+ raise ArgumentError.new('Please provide atleast one input file/pattern')
35
+ end
36
+
37
+ types = { :js => Juicer::Merger::JavaScriptDependencyResolver.new,
38
+ :css => Juicer::Merger::CssDependencyResolver.new }
39
+
40
+ files(args).each do |file|
41
+ type = file.split(".").pop.to_sym
42
+ raise FileNotFoundError.new("Unable to guess type (CSS/JavaScript) of file #{relative(file)}") unless types[type]
43
+
44
+ @io.puts "Dependency chain for #{relative file}:"
45
+ @io.puts " #{relative(types[type].resolve(file)).join("\n ")}\n\n"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,22 +1,38 @@
1
- require 'rubygems'
2
- require 'cmdparse'
3
- require 'tempfile'
1
+ require File.join(File.dirname(__FILE__), "util")
2
+ require File.join(File.dirname(__FILE__), "verify")
3
+ require "cmdparse"
4
+ require "pathname"
4
5
 
5
6
  module Juicer
6
7
  module Command
7
8
  # The compress command combines and minifies CSS and JavaScript files
8
9
  #
9
10
  class Merge < CmdParse::Command
11
+ include Juicer::Command::Util
12
+
10
13
  # Initializes compress command
11
14
  #
12
- def initialize
15
+ def initialize(log = nil)
13
16
  super('merge', false, true)
14
17
  @types = { :js => Juicer::Merger::JavaScriptMerger,
15
18
  :css => Juicer::Merger::StylesheetMerger }
16
- @output = nil
17
- @force = false
18
- @minifyer = "yui_compressor"
19
- @opts = {}
19
+ @output = nil # File to write to
20
+ @force = false # Overwrite existing file if true
21
+ @type = nil # "css" or "js" - for minifyer
22
+ @minifyer = "yui_compressor" # Which minifyer to use
23
+ @opts = {} # Path to minifyer binary
24
+ @arguments = nil # Minifyer arguments
25
+ @ignore = false # Ignore syntax problems if true
26
+ @cache_buster = :soft # What kind of cache buster to use, :soft or :hard
27
+ @hosts = nil # Hosts to use when replacing URLs in stylesheets
28
+ @web_root = nil # Used to understand absolute paths
29
+ @relative_urls = false # Make the merger use relative URLs
30
+ @absolute_urls = false # Make the merger use absolute URLs
31
+ @local_hosts = [] # Host names that are served from :web_root
32
+ @verify = true # Verify js files with JsLint
33
+
34
+ @log = log || Logger.new(STDOUT)
35
+
20
36
  self.short_desc = "Combines and minifies CSS and JavaScript files"
21
37
  self.description = <<-EOF
22
38
  Each file provided as input will be checked for dependencies to other files,
@@ -36,35 +52,75 @@ the YUI Compressor the path should be the path to where the jar file is found.
36
52
  EOF
37
53
 
38
54
  self.options = CmdParse::OptionParserWrapper.new do |opt|
39
- opt.on( '-o', '--output [OUTPUT]', 'Output filename' ) { |filename| @output = filename }
40
- opt.on( '-p', '--path [PATH]', 'Path to compressor binary' ) { |path| @opts[:bin_path] = path }
41
- opt.on( '-m', '--minifyer [MINIFYER]', 'Which minifer to use. Currently only supports YUI Compressor' ) { |name| @minifyer = name }
42
- opt.on( '-f', '--force', 'Force overwrite of target file' ) { @force = true }
55
+ opt.on("-o", "--output file", "Output filename") { |filename| @output = filename }
56
+ opt.on("-p", "--path path", "Path to compressor binary") { |path| @opts[:bin_path] = path }
57
+ opt.on("-m", "--minifyer name", "Which minifer to use. Currently only supports yui_compressor") { |name| @minifyer = name }
58
+ opt.on("-f", "--force", "Force overwrite of target file") { @force = true }
59
+ opt.on("-a", "--arguments arguments", "Arguments to minifyer, escape with quotes") { |arguments| @arguments = arguments }
60
+ opt.on("-i", "--ignore-problems", "Merge and minify even if verifyer finds problems") { @ignore = true }
61
+ opt.on("-s", "--skip-verification", "Skip JsLint verification (js files only). Not recomended!") { @verify = false }
62
+ opt.on("-t", "--type type", "Juicer can only guess type when files have .css or .js extensions. Specify js or\n" +
63
+ (" " * 37) + "css with this option in cases where files have other extensions.") { |type| @type = type.to_sym }
64
+ opt.on("-h", "--hosts hosts", "Cycle asset hosts for referenced urls. Comma separated") { |hosts| @hosts = hosts.split(",") }
65
+ opt.on("-l", "--local-hosts hosts", "Host names that are served from --document-root (can be given cache busters). Comma separated") do |hosts|
66
+ @local_hosts = hosts.split(",")
67
+ end
68
+ opt.on("", "--all-hosts-local", "Treat all hosts as local (ie served from --document-root") { |t| @local_hosts = @hosts }
69
+ opt.on("-r", "--relative-urls", "Convert all referenced URLs to relative URLs. Requires --document-root if\n" +
70
+ (" " * 37) + "absolute URLs are used. Only valid for CSS files") { |t| @relative_urls = true }
71
+ opt.on("-b", "--absolute-urls", "Convert all referenced URLs to absolute URLs. Requires --document-root.\n" +
72
+ (" " * 37) + "Works with cycled asset hosts. Only valid for CSS files") { |t| @absolute_urls = true }
73
+ opt.on("-d", "--document-root dir", "Path to resolve absolute URLs relative to") { |path| @web_root = path }
74
+ opt.on("-c", "--cache-buster type", "none, soft or hard. Default is soft, which adds timestamps to referenced\n" +
75
+ (" " * 37) + "URLs as query parameters. None leaves URLs untouched and hard alters file names") do |type|
76
+ @cache_buster = [:soft, :hard].include?(type.to_sym) ? type.to_sym : nil
77
+ end
43
78
  end
44
79
  end
45
80
 
46
81
  # Execute command
47
82
  #
48
83
  def execute(args)
49
- if args.length == 0
50
- raise OptionParser::ParseError.new('Please provide atleast one input file')
84
+ if (files = files(args)).length == 0
85
+ @log.fatal "Please provide atleast one input file"
86
+ raise SystemExit.new("Please provide atleast one input file")
87
+ end
88
+
89
+ # Figure out which file to output to
90
+ output = output(files.first)
91
+
92
+ # Warn if file already exists
93
+ if File.exists?(output) && !@force
94
+ msg = "Unable to continue, #{output} exists. Run again with --force to overwrite"
95
+ @log.fatal msg
96
+ raise SystemExit.new(msg)
51
97
  end
52
98
 
53
- # If no file name is provided, use name of first input with .min
54
- # prepended to suffix
55
- @output = @output || args[0].sub(/\.([^\.]+)$/, '.min.\1')
99
+ # Set up merger to resolve imports and so on. Do not touch URLs now, if
100
+ # asset host cycling is added at this point, the cache buster WILL be
101
+ # confused
102
+ merger = merger(output).new(files, :relative_urls => @relative_urls,
103
+ :absolute_urls => @absolute_urls,
104
+ :web_root => @web_root,
105
+ :hosts => @hosts)
56
106
 
57
- if File.exists?(@output) && !@force
58
- puts "Unable to continue, #{@output} exists. Run again with --force to overwrite"
59
- exit
107
+ # Fail if syntax trouble (js only)
108
+ if @verify && !Juicer::Command::Verify.check_all(merger.files.reject { |f| f =~ /\.css$/ }, @log)
109
+ @log.error "Problems were detected during verification"
110
+ raise SystemExit.new("Input files contain problems") unless @ignore
111
+ @log.warn "Ignoring detected problems"
60
112
  end
61
113
 
62
- merger = @types[@output.split(/\.([^\.]*)$/)[1].to_sym].new(args)
63
- merger.set_next(minifyer)
64
- merger.save(@output)
114
+ # Set command chain and execute
115
+ merger.set_next(cache_buster(output)).set_next(minifyer)
116
+ merger.save(output)
65
117
 
66
118
  # Print report
67
- puts "Produced #{@output}"
119
+ @log.info "Produced #{relative output} from"
120
+ merger.files.each { |file| @log.info " #{relative file}" }
121
+ rescue FileNotFoundError => err
122
+ # Handle missing document-root option
123
+ puts err.message.sub(/:web_root/, "--document-root")
68
124
  end
69
125
 
70
126
  private
@@ -72,18 +128,61 @@ the YUI Compressor the path should be the path to where the jar file is found.
72
128
  # Resolve and load minifyer
73
129
  #
74
130
  def minifyer
131
+ return nil if @minifyer.nil? || @minifyer == "" || @minifyer.downcase == "none"
132
+
75
133
  begin
76
- minifyer = @minifyer.split("_").collect { |p| p.capitalize! }.join
77
- compressor = Juicer::Minifyer.const_get(minifyer).new(@opts)
134
+ @opts[:bin_path] = File.join(Juicer.home, "lib", @minifyer, "bin") unless @opts[:bin_path]
135
+ compressor = @minifyer.classify(Juicer::Minifyer).new(@opts)
136
+ compressor.set_opts(@arguments) if @arguments
137
+ @log.debug "Using #{@minifyer.camel_case} for minification"
138
+
139
+ return compressor
78
140
  rescue NameError
79
- puts "No such minifyer '#{minifyer}', aborting"
80
- exit
141
+ @log.fatal "No such minifyer '#{@minifyer}', aborting"
142
+ raise SystemExit.new("No such minifyer '#{@minifyer}', aborting")
143
+ rescue FileNotFoundError => e
144
+ @log.fatal e.message
145
+ @log.fatal "Try installing with; juicer install #{@minifyer.underscore}"
146
+ raise SystemExit.new(e.message)
81
147
  rescue Exception => e
82
- puts e.message
83
- exit
148
+ @log.fatal e.message
149
+ raise SystemExit.new(e.message)
84
150
  end
85
151
 
86
- compressor
152
+ nil
153
+ end
154
+
155
+ #
156
+ # Resolve and load merger
157
+ #
158
+ def merger(output = "")
159
+ @type ||= output.split(/\.([^\.]*)$/)[1]
160
+ type = @type.to_sym if @type
161
+
162
+ if !@types.include?(type)
163
+ @log.warn "Unknown type '#{type}', defaulting to 'js'"
164
+ type = :js
165
+ end
166
+
167
+ @types[type]
168
+ end
169
+
170
+ #
171
+ # Load cache buster, only available for CSS files
172
+ #
173
+ def cache_buster(file)
174
+ return nil if !file || file !~ /\.css$/ || @cache_buster.nil?
175
+ Juicer::CssCacheBuster.new(:web_root => @web_root, :type => @cache_buster, :hosts => @local_hosts)
176
+ end
177
+
178
+ #
179
+ # Generate output file name. Optional argument is a filename to base the new
180
+ # name on. It will prepend the original suffix with ".min"
181
+ #
182
+ def output(file = "#{Time.now.to_i}.tmp")
183
+ @output = File.dirname(file) if @output.nil?
184
+ @output = File.join(@output, File.basename(file).sub(/\.([^\.]+)$/, '.min.\1')) if File.directory?(@output)
185
+ @output = File.expand_path(@output)
87
186
  end
88
187
  end
89
188
  end
@@ -0,0 +1,32 @@
1
+ require "pathname"
2
+
3
+ module Juicer
4
+ module Command
5
+ # Utilities for Juicer command objects
6
+ #
7
+ module Util
8
+ # Returns an array of files from a variety of input. Input may be a single
9
+ # file, a single glob pattern or multiple files and/or patterns. It may
10
+ # even be an array of mixed input.
11
+ #
12
+ def files(*args)
13
+ args.flatten.collect { |file| Dir.glob(file) }.flatten
14
+ end
15
+
16
+ #
17
+ # Uses Pathname to calculate the shortest relative path from +path+ to
18
+ # +reference_path+ (default is +Dir.cwd+)
19
+ #
20
+ def relative(paths, reference_path = Dir.pwd)
21
+ paths = [paths].flatten.collect do |path|
22
+ path = Pathname.new(File.expand_path(path))
23
+ reference_path = Pathname.new(File.expand_path(reference_path))
24
+ path.relative_path_from(reference_path).to_s
25
+ end
26
+
27
+ paths.length == 1 ? paths.first : paths
28
+ end
29
+ end
30
+ end
31
+ end
32
+