lib/yui/compressor.rb



require "shellwords"
require "stringio"
require "tempfile"
require "rbconfig"

module YUI #:nodoc:
  class Compressor
    VERSION = "0.12.0"

    class Error < StandardError; end
    class OptionError   < Error; end
    class RuntimeError  < Error; end

    attr_reader :options

    def self.default_options #:nodoc:
      { :charset => "utf-8", :line_break => nil }
    end

    def self.compressor_type #:nodoc:
      raise Error, "create a CssCompressor or JavaScriptCompressor instead"
    end

    def initialize(options = {}) #:nodoc:
      @options = self.class.default_options.merge(options)
      @command = [path_to_java]
      @command.push(*java_opts)
      @command.push("-jar")
      @command.push(path_to_jar_file)
      @command.push(*(command_option_for_type + command_options))
      @command.compact!
    end

    def command #:nodoc:
      if RbConfig::CONFIG['host_os'] =~ /mswin|mingw/
        # Shellwords is only for bourne shells, so windows shells get this
        # extremely remedial escaping
        escaped_cmd = @command.map do |word|
          if word =~ / /
            word = "\"%s\"" % word
          end

          word
        end
      else
        escaped_cmd = @command.map { |word| Shellwords.escape(word) }
      end

      escaped_cmd.join(" ")
    end

    # Compress a stream or string of code with YUI Compressor. (A stream is
    # any object that responds to +read+ and +close+ like an IO.) If a block
    # is given, you can read the compressed code from the block's argument.
    # Otherwise, +compress+ returns a string of compressed code.
    #
    # ==== Example: Compress CSS
    #   compressor = YUI::CssCompressor.new
    #   compressor.compress(<<-END_CSS)
    #     div.error {
    #       color: red;
    #     }
    #     div.warning {
    #       display: none;
    #     }
    #   END_CSS
    #   # => "div.error{color:red;}div.warning{display:none;}"
    #
    # ==== Example: Compress JavaScript
    #   compressor = YUI::JavaScriptCompressor.new
    #   compressor.compress('(function () { var foo = {}; foo["bar"] = "baz"; })()')
    #   # => "(function(){var foo={};foo.bar=\"baz\"})();"
    #
    # ==== Example: Compress and gzip a file on disk
    #   File.open("my.js", "r") do |source|
    #     Zlib::GzipWriter.open("my.js.gz", "w") do |gzip|
    #       compressor.compress(source) do |compressed|
    #         while buffer = compressed.read(4096)
    #           gzip.write(buffer)
    #         end
    #       end
    #     end
    #   end
    #
    def compress(stream_or_string)
      streamify(stream_or_string) do |stream|
        tempfile = Tempfile.new('yui_compress')
        tempfile.write stream.read
        tempfile.flush
        full_command = "%s %s" % [command, tempfile.path]

        begin
          output = `#{full_command}`
        rescue Exception => e
          # windows shells tend to blow up here when the command fails
          raise RuntimeError, "compression failed: %s" % e.message
        ensure
          tempfile.close!
        end

        if $?.exitstatus.zero?
          output
        else
          # Bourne shells tend to blow up here when the command fails, usually
          # because java is missing
          raise RuntimeError, "Command '%s' returned non-zero exit status" %
            full_command
        end
      end
    end

    private
      def command_options
        options.inject([]) do |command_options, (name, argument)|
          method = begin
            method(:"command_option_for_#{name}")
          rescue NameError
            raise OptionError, "undefined option #{name.inspect}"
          end

          command_options.concat(method.call(argument))
        end
      end

      def path_to_java
        options.delete(:java) || "java"
      end

      def java_opts
        options.delete(:java_opts).to_s.split(/\s+/)
      end

      def path_to_jar_file
        options.delete(:jar_file) || File.join(File.dirname(__FILE__), *%w".. yuicompressor-2.4.8.jar")
      end

      def streamify(stream_or_string)
        if stream_or_string.respond_to?(:read)
          yield stream_or_string
        else
          yield StringIO.new(stream_or_string.to_s)
        end
      end

      def command_option_for_type
        ["--type", self.class.compressor_type.to_s]
      end

      def command_option_for_charset(charset)
        ["--charset", charset.to_s]
      end

      def command_option_for_line_break(line_break)
        line_break ? ["--line-break", line_break.to_s] : []
      end
  end

  class CssCompressor < Compressor
    def self.compressor_type #:nodoc:
      "css"
    end

    # Creates a new YUI::CssCompressor for minifying CSS code.
    #
    # Options are:
    #
    # <tt>:charset</tt>::    Specifies the character encoding to use. Defaults to
    #                        <tt>"utf-8"</tt>.
    # <tt>:line_break</tt>:: By default, CSS will be compressed onto a single
    #                        line. Use this option to specify the maximum
    #                        number of characters in each line before a newline
    #                        is added. If <tt>:line_break</tt> is 0, a newline
    #                        is added after each CSS rule.
    #
    def initialize(options = {})
      super
    end
  end

  class JavaScriptCompressor < Compressor
    def self.compressor_type #:nodoc:
      "js"
    end

    def self.default_options #:nodoc:
      super.merge(
        :munge    => false,
        :optimize => true,
        :preserve_semicolons => false
      )
    end

    # Creates a new YUI::JavaScriptCompressor for minifying JavaScript code.
    #
    # Options are:
    #
    # <tt>:charset</tt>::    Specifies the character encoding to use. Defaults to
    #                        <tt>"utf-8"</tt>.
    # <tt>:line_break</tt>:: By default, JavaScript will be compressed onto a
    #                        single line. Use this option to specify the
    #                        maximum number of characters in each line before a
    #                        newline is added. If <tt>:line_break</tt> is 0, a
    #                        newline is added after each JavaScript statement.
    # <tt>:munge</tt>::      Specifies whether YUI Compressor should shorten local
    #                        variable names when possible. Defaults to +false+.
    # <tt>:optimize</tt>::   Specifies whether YUI Compressor should optimize
    #                        JavaScript object property access and object literal
    #                        declarations to use as few characters as possible.
    #                        Defaults to +true+.
    # <tt>:preserve_semicolons</tt>:: Defaults to +false+. If +true+, YUI
    #                                 Compressor will ensure semicolons exist
    #                                 after each statement to appease tools like
    #                                 JSLint.
    #
    def initialize(options = {})
      super
    end

    private
      def command_option_for_munge(munge)
        munge ? [] : ["--nomunge"]
      end

      def command_option_for_optimize(optimize)
        optimize ? [] : ["--disable-optimizations"]
      end

      def command_option_for_preserve_semicolons(preserve_semicolons)
        preserve_semicolons ? ["--preserve-semi"] : []
      end
  end
end