juicer 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. data/History.txt +10 -0
  2. data/Manifest.txt +58 -0
  3. data/Rakefile +44 -0
  4. data/Readme.rdoc +143 -0
  5. data/bin/juicer +8 -0
  6. data/lib/juicer.rb +70 -0
  7. data/lib/juicer/binary.rb +173 -0
  8. data/lib/juicer/cache_buster.rb +45 -0
  9. data/lib/juicer/chainable.rb +106 -0
  10. data/lib/juicer/cli.rb +56 -0
  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 +185 -0
  14. data/lib/juicer/command/util.rb +32 -0
  15. data/lib/juicer/command/verify.rb +60 -0
  16. data/lib/juicer/core.rb +59 -0
  17. data/lib/juicer/css_cache_buster.rb +99 -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 -0
  24. data/lib/juicer/merger/css_dependency_resolver.rb +25 -0
  25. data/lib/juicer/merger/dependency_resolver.rb +82 -0
  26. data/lib/juicer/merger/javascript_dependency_resolver.rb +21 -0
  27. data/lib/juicer/merger/javascript_merger.rb +30 -0
  28. data/lib/juicer/merger/stylesheet_merger.rb +112 -0
  29. data/lib/juicer/minifyer/yui_compressor.rb +129 -0
  30. data/tasks/ann.rake +80 -0
  31. data/tasks/bones.rake +20 -0
  32. data/tasks/gem.rake +201 -0
  33. data/tasks/git.rake +40 -0
  34. data/tasks/notes.rake +27 -0
  35. data/tasks/post_load.rake +34 -0
  36. data/tasks/rdoc.rake +50 -0
  37. data/tasks/rubyforge.rake +55 -0
  38. data/tasks/setup.rb +300 -0
  39. data/tasks/spec.rake +54 -0
  40. data/tasks/svn.rake +47 -0
  41. data/tasks/test.rake +40 -0
  42. data/tasks/test/setup.rake +35 -0
  43. data/test/bin/jslint.js +474 -0
  44. data/test/bin/rhino1_7R1.zip +0 -0
  45. data/test/bin/rhino1_7R2-RC1.zip +0 -0
  46. data/test/bin/yuicompressor +238 -0
  47. data/test/bin/yuicompressor-2.3.5.zip +0 -0
  48. data/test/bin/yuicompressor-2.4.2.zip +0 -0
  49. data/test/juicer/command/test_install.rb +53 -0
  50. data/test/juicer/command/test_list.rb +69 -0
  51. data/test/juicer/command/test_merge.rb +155 -0
  52. data/test/juicer/command/test_util.rb +54 -0
  53. data/test/juicer/command/test_verify.rb +33 -0
  54. data/test/juicer/install/test_installer_base.rb +195 -0
  55. data/test/juicer/install/test_jslint_installer.rb +54 -0
  56. data/test/juicer/install/test_rhino_installer.rb +57 -0
  57. data/test/juicer/install/test_yui_compressor_installer.rb +56 -0
  58. data/test/juicer/merger/test_base.rb +122 -0
  59. data/test/juicer/merger/test_css_dependency_resolver.rb +36 -0
  60. data/test/juicer/merger/test_javascript_dependency_resolver.rb +39 -0
  61. data/test/juicer/merger/test_javascript_merger.rb +74 -0
  62. data/test/juicer/merger/test_stylesheet_merger.rb +178 -0
  63. data/test/juicer/minifyer/test_yui_compressor.rb +159 -0
  64. data/test/juicer/test_cache_buster.rb +58 -0
  65. data/test/juicer/test_chainable.rb +94 -0
  66. data/test/juicer/test_core.rb +47 -0
  67. data/test/juicer/test_css_cache_buster.rb +72 -0
  68. data/test/juicer/test_jslint.rb +33 -0
  69. data/test/test_helper.rb +146 -0
  70. data/test/test_juicer.rb +4 -0
  71. metadata +194 -0
@@ -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
@@ -0,0 +1,106 @@
1
+ module Juicer
2
+ #
3
+ # Facilitates the chain of responsibility pattern. Wraps given methods and
4
+ # calls them in a chain.
5
+ #
6
+ # To make an object chainable, simply include the module and call the class
7
+ # method chain_method for each method that should be chained.
8
+ #
9
+ # Example is a simplified version of the Wikipedia one
10
+ # (http://en.wikipedia.org/wiki/Chain-of-responsibility_pattern)
11
+ #
12
+ # class Logger
13
+ # include Juicer::Chainable
14
+ #
15
+ # ERR = 3
16
+ # NOTICE = 5
17
+ # DEBUG = 7
18
+ #
19
+ # def initialize(level)
20
+ # @level = level
21
+ # end
22
+ #
23
+ # def log(str, level)
24
+ # if level <= @level
25
+ # write str
26
+ # else
27
+ # abort_chain
28
+ # end
29
+ # end
30
+ #
31
+ # def write(str)
32
+ # puts str
33
+ # end
34
+ #
35
+ # chain_method :message
36
+ # end
37
+ #
38
+ # class EmailLogger < Logger
39
+ # def write(str)
40
+ # p "Logging by email"
41
+ # # ...
42
+ # end
43
+ # end
44
+ #
45
+ # logger = Logger.new(Logger::NOTICE)
46
+ # logger.next_in_chain = EmailLogger.new(Logger::ERR)
47
+ #
48
+ # logger.log("Some message", Logger::DEBUG) # Ignored
49
+ # logger.log("A warning", Logger::NOTICE) # Logged to console
50
+ # logger.log("An error", Logger::ERR) # Logged to console and email
51
+ #
52
+ module Chainable
53
+
54
+ #
55
+ # Add the chain_method to classes that includes the module
56
+ #
57
+ def self.included(base)
58
+ base.extend(ClassMethods)
59
+ end
60
+
61
+ #
62
+ # Sets the next command in the chain
63
+ #
64
+ def next_in_chain=(next_obj)
65
+ @_next_in_chain = next_obj
66
+ next_obj || self
67
+ end
68
+
69
+ alias_method :set_next, :next_in_chain=
70
+
71
+ #
72
+ # Get next command in chain
73
+ #
74
+ def next_in_chain
75
+ @_next_in_chain ||= nil
76
+ @_next_in_chain
77
+ end
78
+
79
+ private
80
+ #
81
+ # Abort the chain for the current message
82
+ #
83
+ def abort_chain
84
+ @_abort_chain = true
85
+ end
86
+
87
+ module ClassMethods
88
+ #
89
+ # Sets up a method for chaining
90
+ #
91
+ def chain_method(method)
92
+ original_method = "execute_#{method}".to_sym
93
+ alias_method original_method, method
94
+
95
+ self.class_eval <<-RUBY
96
+ def #{method}(*args, &block)
97
+ @_abort_chain = false
98
+ #{original_method}(*args, &block)
99
+ next_in_chain.#{method}(*args, &block) if !@_abort_chain && next_in_chain
100
+ @_abort_chain = false
101
+ end
102
+ RUBY
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,56 @@
1
+ require "cmdparse"
2
+
3
+ # Command line interpreter for Juicer
4
+ #
5
+ module Juicer
6
+ class Cli
7
+
8
+ def initialize
9
+ @log = Juicer::LOGGER
10
+ @log.level = Logger::INFO
11
+ end
12
+
13
+ # Set up command parser and parse arguments
14
+ #
15
+ def parse(arguments = ARGV)
16
+ @cmd = CmdParse::CommandParser.new(true, true)
17
+ @cmd.program_name = "juicer"
18
+ @cmd.program_version = Juicer.version.split(".")
19
+
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
25
+
26
+ add_commands
27
+ @cmd.parse(arguments)
28
+ @log.close
29
+ rescue SystemExit
30
+ exit
31
+ end
32
+
33
+ # Run CLI
34
+ #
35
+ def self.run(arguments = ARGV)
36
+ juicer = self.new
37
+ juicer.parse(arguments)
38
+ end
39
+
40
+ private
41
+ # Adds commands supported by juicer. Instantiates all classes in the
42
+ # Juicer::Command namespace.
43
+ #
44
+ def add_commands
45
+ @cmd.add_command(CmdParse::HelpCommand.new)
46
+ @cmd.add_command(CmdParse::VersionCommand.new)
47
+
48
+ if Juicer.const_defined?("Command")
49
+ Juicer::Command.constants.each do |const|
50
+ const = Juicer::Command.const_get(const)
51
+ @cmd.add_command(const.new(@log)) if const.kind_of?(Class)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ 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
@@ -0,0 +1,185 @@
1
+ require File.join(File.dirname(__FILE__), "util")
2
+ require File.join(File.dirname(__FILE__), "verify")
3
+ require "cmdparse"
4
+ require "pathname"
5
+
6
+ module Juicer
7
+ module Command
8
+ # The compress command combines and minifies CSS and JavaScript files
9
+ #
10
+ class Merge < CmdParse::Command
11
+ include Juicer::Command::Util
12
+
13
+ # Initializes compress command
14
+ #
15
+ def initialize(log = nil)
16
+ super('merge', false, true)
17
+ @types = { :js => Juicer::Merger::JavaScriptMerger,
18
+ :css => Juicer::Merger::StylesheetMerger }
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
+
33
+ @log = log || Logger.new(STDOUT)
34
+
35
+ self.short_desc = "Combines and minifies CSS and JavaScript files"
36
+ self.description = <<-EOF
37
+ Each file provided as input will be checked for dependencies to other files,
38
+ and those files will be added to the final output
39
+
40
+ For CSS files the dependency checking is done through regular @import
41
+ statements.
42
+
43
+ For JavaScript files you can tell Juicer about dependencies through special
44
+ comment switches. These should appear inside a multi-line comment, specifically
45
+ inside the first multi-line comment. The switch is @depend or @depends, your
46
+ choice.
47
+
48
+ The -m --minifyer switch can be used to select which minifyer to use. Currently
49
+ only YUI Compressor is supported, ie -m yui_compressor (default). When using
50
+ the YUI Compressor the path should be the path to where the jar file is found.
51
+ EOF
52
+
53
+ self.options = CmdParse::OptionParserWrapper.new do |opt|
54
+ opt.on("-o", "--output file", "Output filename") { |filename| @output = filename }
55
+ opt.on("-p", "--path path", "Path to compressor binary") { |path| @opts[:bin_path] = path }
56
+ opt.on("-m", "--minifyer name", "Which minifer to use. Currently only supports yui_compressor") { |name| @minifyer = name }
57
+ opt.on("-f", "--force", "Force overwrite of target file") { @force = true }
58
+ opt.on("-a", "--arguments arguments", "Arguments to minifyer, escape with quotes") { |arguments| @arguments = arguments }
59
+ opt.on("-i", "--ignore-problems", "Merge and minify even if verifyer finds problems") { @ignore = true }
60
+ opt.on("-t", "--type type", "Juicer can only guess type when files have .css or .js extensions. Specify js or\n" +
61
+ (" " * 37) + "css with this option in cases where files have other extensions.") { |type| @type = type.to_sym }
62
+ opt.on("-h", "--hosts hosts", "Cycle asset hosts for referenced urls. Comma separated") { |hosts| @hosts = hosts.split(",") }
63
+ opt.on("-l", "--local-hosts hosts", "Host names that are served from --document-root (can be given cache busters). Comma separated") do |hosts|
64
+ @local_hosts = hosts.split(",")
65
+ end
66
+ opt.on("", "--all-hosts-local", "Treat all hosts as local (ie served from --document-root") { |t| @local_hosts = @hosts }
67
+ opt.on("-r", "--relative-urls", "Convert all referenced URLs to relative URLs. Requires --document-root if\n" +
68
+ (" " * 37) + "absolute URLs are used. Only valid for CSS files") { |t| @relative_urls = true }
69
+ opt.on("-b", "--absolute-urls", "Convert all referenced URLs to absolute URLs. Requires --document-root.\n" +
70
+ (" " * 37) + "Works with cycled asset hosts. Only valid for CSS files") { |t| @absolute_urls = true }
71
+ opt.on("-d", "--document-root dir", "Path to resolve absolute URLs relative to") { |path| @web_root = path }
72
+ opt.on("-c", "--cache-buster type", "none, soft or hard. Default is soft, which adds timestamps to referenced\n" +
73
+ (" " * 37) + "URLs as query parameters. None leaves URLs untouched and hard alters file names") do |type|
74
+ @cache_buster = [:soft, :hard].include?(type.to_sym) ? type.to_sym : nil
75
+ end
76
+ end
77
+ end
78
+
79
+ # Execute command
80
+ #
81
+ def execute(args)
82
+ if (files = files(args)).length == 0
83
+ @log.fatal "Please provide atleast one input file"
84
+ raise SystemExit.new("Please provide atleast one input file")
85
+ end
86
+
87
+ # Figure out which file to output to
88
+ output = output(files.first)
89
+
90
+ # Warn if file already exists
91
+ if File.exists?(output) && !@force
92
+ msg = "Unable to continue, #{output} exists. Run again with --force to overwrite"
93
+ @log.fatal msg
94
+ raise SystemExit.new(msg)
95
+ end
96
+
97
+ # Set up merger to resolve imports and so on. Do not touch URLs now, if
98
+ # asset host cycling is added at this point, the cache buster WILL be
99
+ # confused
100
+ merger = merger(output).new(files, :relative_urls => @relative_urls,
101
+ :absolute_urls => @absolute_urls,
102
+ :web_root => @web_root,
103
+ :hosts => @hosts)
104
+
105
+ # Fail if syntax trouble (js only)
106
+ if !Juicer::Command::Verify.check_all(merger.files.reject { |f| f =~ /\.css$/ }, @log)
107
+ @log.error "Problems were detected during verification"
108
+ raise SystemExit.new("Input files contain problems") unless @ignore
109
+ @log.warn "Ignoring detected problems"
110
+ end
111
+
112
+ # Set command chain and execute
113
+ merger.set_next(cache_buster(output)).set_next(minifyer)
114
+ merger.save(output)
115
+
116
+ # Print report
117
+ @log.info "Produced #{relative output} from"
118
+ merger.files.each { |file| @log.info " #{relative file}" }
119
+ rescue FileNotFoundError => err
120
+ # Handle missing document-root option
121
+ puts err.message.sub(/:web_root/, "--document-root")
122
+ end
123
+
124
+ private
125
+ #
126
+ # Resolve and load minifyer
127
+ #
128
+ def minifyer
129
+ return nil if @minifyer.nil? || @minifyer == "" || @minifyer.downcase == "none"
130
+
131
+ begin
132
+ @opts[:bin_path] = File.join(Juicer.home, "lib", @minifyer, "bin") unless @opts[:bin_path]
133
+ compressor = @minifyer.classify(Juicer::Minifyer).new(@opts)
134
+ compressor.set_opts(@arguments) if @arguments
135
+ @log.debug "Using #{@minifyer.camel_case} for minification"
136
+
137
+ return compressor
138
+ rescue NameError
139
+ @log.fatal "No such minifyer '#{@minifyer}', aborting"
140
+ raise SystemExit.new("No such minifyer '#{@minifyer}', aborting")
141
+ rescue FileNotFoundError => e
142
+ @log.fatal e.message
143
+ @log.fatal "Try installing with; juicer install #{@minifyer.underscore}"
144
+ raise SystemExit.new(e.message)
145
+ rescue Exception => e
146
+ @log.fatal e.message
147
+ raise SystemExit.new(e.message)
148
+ end
149
+
150
+ nil
151
+ end
152
+
153
+ #
154
+ # Resolve and load merger
155
+ #
156
+ def merger(output = "")
157
+ @type ||= output.split(/\.([^\.]*)$/)[1]
158
+ type = @type.to_sym if @type
159
+
160
+ if !@types.include?(type)
161
+ @log.warn "Unknown type '#{type}', defaulting to 'js'"
162
+ type = :js
163
+ end
164
+
165
+ @types[type]
166
+ end
167
+
168
+ #
169
+ # Load cache buster, only available for CSS files
170
+ #
171
+ def cache_buster(file)
172
+ return nil if !file || file !~ /\.css$/
173
+ Juicer::CssCacheBuster.new(:web_root => @web_root, :type => @cache_buster, :hosts => @local_hosts)
174
+ end
175
+
176
+ #
177
+ # Generate output file name. Optional argument is a filename to base the new
178
+ # name on. It will prepend the original suffix with ".min"
179
+ #
180
+ def output(file = "#{Time.now.to_i}.tmp")
181
+ @output || file.sub(/\.([^\.]+)$/, '.min.\1')
182
+ end
183
+ end
184
+ end
185
+ end