/lib/jammit/compressor.rb

http://github.com/documentcloud/jammit · Ruby · 266 lines · 177 code · 33 blank · 56 comment · 24 complexity · b49db26e8de4c2cf35f673a9ebccdc37 MD5 · raw file

  1. module Jammit
  2. # Uses the YUI Compressor or Closure Compiler to compress JavaScript.
  3. # Always uses YUI to compress CSS (Which means that Java must be installed.)
  4. # Also knows how to create a concatenated JST file.
  5. # If "embed_assets" is turned on, creates "mhtml" and "datauri" versions of
  6. # all stylesheets, with all enabled assets inlined into the css.
  7. class Compressor
  8. # Mapping from extension to mime-type of all embeddable assets.
  9. EMBED_MIME_TYPES = {
  10. '.png' => 'image/png',
  11. '.jpg' => 'image/jpeg',
  12. '.jpeg' => 'image/jpeg',
  13. '.gif' => 'image/gif',
  14. '.tif' => 'image/tiff',
  15. '.tiff' => 'image/tiff',
  16. '.ttf' => 'application/x-font-ttf',
  17. '.otf' => 'font/opentype',
  18. '.woff' => 'application/x-font-woff'
  19. }
  20. # Font extensions for which we allow embedding:
  21. EMBED_EXTS = EMBED_MIME_TYPES.keys
  22. EMBED_FONTS = ['.ttf', '.otf', '.woff']
  23. # (32k - padding) maximum length for data-uri assets (an IE8 limitation).
  24. MAX_IMAGE_SIZE = 32700
  25. # CSS asset-embedding regexes for URL rewriting.
  26. EMBED_DETECTOR = /url\(['"]?([^\s)]+\.[a-z]+)(\?\d+)?['"]?\)/
  27. EMBEDDABLE = /[\A\/]embed\//
  28. EMBED_REPLACER = /url\(__EMBED__(.+?)(\?\d+)?\)/
  29. # MHTML file constants.
  30. MHTML_START = "/*\r\nContent-Type: multipart/related; boundary=\"MHTML_MARK\"\r\n\r\n"
  31. MHTML_SEPARATOR = "--MHTML_MARK\r\n"
  32. MHTML_END = "\r\n--MHTML_MARK--\r\n*/\r\n"
  33. # JST file constants.
  34. JST_START = "(function(){"
  35. JST_END = "})();"
  36. JAVASCRIPT_COMPRESSORS = {
  37. :jsmin => Jammit.javascript_compressors.include?(:jsmin) ? Jammit::JsminCompressor : nil,
  38. :yui => Jammit.javascript_compressors.include?(:yui) ? YUI::JavaScriptCompressor : nil,
  39. :closure => Jammit.javascript_compressors.include?(:closure) ? Closure::Compiler : nil,
  40. :uglifier => Jammit.javascript_compressors.include?(:uglifier) ? Jammit::Uglifier : nil
  41. }
  42. CSS_COMPRESSORS = {
  43. :cssmin => Jammit.css_compressors.include?(:cssmin) ? Jammit::CssminCompressor : nil,
  44. :yui => Jammit.css_compressors.include?(:yui) ? YUI::CssCompressor : nil,
  45. :sass => Jammit.css_compressors.include?(:sass) ? Jammit::SassCompressor : nil
  46. }
  47. JAVASCRIPT_DEFAULT_OPTIONS = {
  48. :jsmin => {},
  49. :yui => {:munge => true},
  50. :closure => {},
  51. :uglifier => {:copyright => false}
  52. }
  53. # CSS compression can be provided with YUI Compressor or sass. JS
  54. # compression can be provided with YUI Compressor, Google Closure
  55. # Compiler or UglifyJS.
  56. def initialize
  57. if Jammit.javascript_compressors.include?(:yui) || Jammit.javascript_compressors.include?(:closure) || Jammit.css_compressors.include?(:yui)
  58. Jammit.check_java_version
  59. end
  60. css_flavor = Jammit.css_compressor || Jammit::DEFAULT_CSS_COMPRESSOR
  61. @css_compressor = CSS_COMPRESSORS[css_flavor].new(Jammit.css_compressor_options || {})
  62. js_flavor = Jammit.javascript_compressor || Jammit::DEFAULT_JAVASCRIPT_COMPRESSOR
  63. @options = JAVASCRIPT_DEFAULT_OPTIONS[js_flavor].merge(Jammit.compressor_options || {})
  64. @js_compressor = JAVASCRIPT_COMPRESSORS[js_flavor].new(@options)
  65. end
  66. # Concatenate together a list of JavaScript paths, and pass them through the
  67. # YUI Compressor (with munging enabled). JST can optionally be included.
  68. def compress_js(paths)
  69. if (jst_paths = paths.grep(Jammit.template_extension_matcher)).empty?
  70. js = concatenate(paths)
  71. else
  72. js = concatenate(paths - jst_paths) + compile_jst(jst_paths)
  73. end
  74. Jammit.compress_assets ? @js_compressor.compress(js) : js
  75. end
  76. # Concatenate and compress a list of CSS stylesheets. When compressing a
  77. # :datauri or :mhtml variant, post-processes the result to embed
  78. # referenced assets.
  79. def compress_css(paths, variant=nil, asset_url=nil)
  80. @asset_contents = {}
  81. css = concatenate_and_tag_assets(paths, variant)
  82. css = @css_compressor.compress(css) if Jammit.compress_assets
  83. case variant
  84. when nil then return css
  85. when :datauri then return with_data_uris(css)
  86. when :mhtml then return with_mhtml(css, asset_url)
  87. else raise PackageNotFound, "\"#{variant}\" is not a valid stylesheet variant"
  88. end
  89. end
  90. # Compiles a single JST file by writing out a javascript that adds
  91. # template properties to a top-level template namespace object. Adds a
  92. # JST-compilation function to the top of the package, unless you've
  93. # specified your own preferred function, or turned it off.
  94. # JST templates are named with the basename of their file.
  95. def compile_jst(paths)
  96. namespace = Jammit.template_namespace
  97. paths = paths.grep(Jammit.template_extension_matcher).sort
  98. base_path = find_base_path(paths)
  99. compiled = paths.map do |path|
  100. contents = read_binary_file(path)
  101. contents = contents.gsub(/\r?\n/, "\\n").gsub("'", '\\\\\'')
  102. name = template_name(path, base_path)
  103. "#{namespace}['#{name}'] = #{Jammit.template_function}('#{contents}');"
  104. end
  105. compiler = Jammit.include_jst_script ? read_binary_file(DEFAULT_JST_SCRIPT) : '';
  106. setup_namespace = "#{namespace} = #{namespace} || {};"
  107. [JST_START, setup_namespace, compiler, compiled, JST_END].flatten.join("\n")
  108. end
  109. private
  110. # Given a set of paths, find a common prefix path.
  111. def find_base_path(paths)
  112. return nil if paths.length <= 1
  113. paths.sort!
  114. first = paths.first.split('/')
  115. last = paths.last.split('/')
  116. i = 0
  117. while first[i] == last[i] && i <= first.length
  118. i += 1
  119. end
  120. res = first.slice(0, i).join('/')
  121. res.empty? ? nil : res
  122. end
  123. # Determine the name of a JS template. If there's a common base path, use
  124. # the namespaced prefix. Otherwise, simply use the filename.
  125. def template_name(path, base_path)
  126. return File.basename(path, ".#{Jammit.template_extension}") unless base_path
  127. path.gsub(/\A#{Regexp.escape(base_path)}\/(.*)\.#{Jammit.template_extension}\Z/, '\1')
  128. end
  129. # In order to support embedded assets from relative paths, we need to
  130. # expand the paths before contatenating the CSS together and losing the
  131. # location of the original stylesheet path. Validate the assets while we're
  132. # at it.
  133. def concatenate_and_tag_assets(paths, variant=nil)
  134. stylesheets = [paths].flatten.map do |css_path|
  135. contents = read_binary_file(css_path)
  136. contents.gsub(EMBED_DETECTOR) do |url|
  137. ipath, cpath = Pathname.new($1), Pathname.new(File.expand_path(css_path))
  138. is_url = URI.parse($1).absolute?
  139. is_url ? url : "url(#{construct_asset_path(ipath, cpath, variant)})"
  140. end
  141. end
  142. stylesheets.join("\n")
  143. end
  144. # Re-write all enabled asset URLs in a stylesheet with their corresponding
  145. # Data-URI Base-64 encoded asset contents.
  146. def with_data_uris(css)
  147. css.gsub(EMBED_REPLACER) do |url|
  148. "url(\"data:#{mime_type($1)};charset=utf-8;base64,#{encoded_contents($1)}\")"
  149. end
  150. end
  151. # Re-write all enabled asset URLs in a stylesheet with the MHTML equivalent.
  152. # The newlines ("\r\n") in the following method are critical. Without them
  153. # your MHTML will look identical, but won't work.
  154. def with_mhtml(css, asset_url)
  155. paths, index = {}, 0
  156. css = css.gsub(EMBED_REPLACER) do |url|
  157. i = paths[$1] ||= "#{index += 1}-#{File.basename($1)}"
  158. "url(mhtml:#{asset_url}!#{i})"
  159. end
  160. mhtml = paths.sort.map do |path, identifier|
  161. mime, contents = mime_type(path), encoded_contents(path)
  162. [MHTML_SEPARATOR, "Content-Location: #{identifier}\r\n", "Content-Type: #{mime}\r\n", "Content-Transfer-Encoding: base64\r\n\r\n", contents, "\r\n"]
  163. end
  164. [MHTML_START, mhtml, MHTML_END, css].flatten.join('')
  165. end
  166. # Return a rewritten asset URL for a new stylesheet -- the asset should
  167. # be tagged for embedding if embeddable, and referenced at the correct level
  168. # if relative.
  169. def construct_asset_path(asset_path, css_path, variant)
  170. public_path = absolute_path(asset_path, css_path)
  171. return "__EMBED__#{public_path}" if embeddable?(public_path, variant)
  172. source = asset_path.absolute? || ! Jammit.rewrite_relative_paths ? asset_path.to_s : relative_path(public_path)
  173. rewrite_asset_path(source, public_path)
  174. end
  175. # Get the site-absolute public path for an asset file path that may or may
  176. # not be relative, given the path of the stylesheet that contains it.
  177. def absolute_path(asset_pathname, css_pathname)
  178. (asset_pathname.absolute? ?
  179. Pathname.new(File.join(Jammit.public_root, asset_pathname)) :
  180. css_pathname.dirname + asset_pathname).cleanpath
  181. end
  182. # CSS assets that are referenced by relative paths, and are *not* being
  183. # embedded, must be rewritten relative to the newly-merged stylesheet path.
  184. def relative_path(absolute_path)
  185. File.join('../', absolute_path.sub(Jammit.public_root, ''))
  186. end
  187. # Similar to the AssetTagHelper's method of the same name, this will
  188. # append the RAILS_ASSET_ID cache-buster to URLs, if it's defined.
  189. def rewrite_asset_path(path, file_path)
  190. asset_id = rails_asset_id(file_path)
  191. (!asset_id || asset_id == '') ? path : "#{path}?#{asset_id}"
  192. end
  193. # Similar to the AssetTagHelper's method of the same name, this will
  194. # determine the correct asset id for a file.
  195. def rails_asset_id(path)
  196. asset_id = ENV["RAILS_ASSET_ID"]
  197. return asset_id if asset_id
  198. File.exists?(path) ? File.mtime(path).to_i.to_s : ''
  199. end
  200. # An asset is valid for embedding if it exists, is less than 32K, and is
  201. # stored somewhere inside of a folder named "embed". IE does not support
  202. # Data-URIs larger than 32K, and you probably shouldn't be embedding assets
  203. # that large in any case. Because we need to check the base64 length here,
  204. # save it so that we don't have to compute it again later.
  205. def embeddable?(asset_path, variant)
  206. font = EMBED_FONTS.include?(asset_path.extname)
  207. return false unless variant
  208. return false unless asset_path.to_s.match(EMBEDDABLE) && asset_path.exist?
  209. return false unless EMBED_EXTS.include?(asset_path.extname)
  210. return false unless font || encoded_contents(asset_path).length < MAX_IMAGE_SIZE
  211. return false if font && variant == :mhtml
  212. return true
  213. end
  214. # Return the Base64-encoded contents of an asset on a single line.
  215. def encoded_contents(asset_path)
  216. return @asset_contents[asset_path] if @asset_contents[asset_path]
  217. data = read_binary_file(asset_path)
  218. @asset_contents[asset_path] = Base64.encode64(data).gsub(/\n/, '')
  219. end
  220. # Grab the mime-type of an asset, by filename.
  221. def mime_type(asset_path)
  222. EMBED_MIME_TYPES[File.extname(asset_path)]
  223. end
  224. # Concatenate together a list of asset files.
  225. def concatenate(paths)
  226. [paths].flatten.map {|p| read_binary_file(p) }.join("\n")
  227. end
  228. # `File.read`, but in "binary" mode.
  229. def read_binary_file(path)
  230. File.open(path, 'rb:UTF-8') {|f| f.read }
  231. end
  232. end
  233. end