jammit-core 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. data/LICENSE +22 -0
  2. data/README +24 -0
  3. data/README.rdoc +17 -0
  4. data/Rakefile +59 -0
  5. data/VERSION +1 -0
  6. data/bin/jammit +13 -0
  7. data/lib/assets.example.yml +14 -0
  8. data/lib/jammit-core.rb +219 -0
  9. data/lib/jammit-core/cli.rb +87 -0
  10. data/lib/jammit-core/command_line.rb +79 -0
  11. data/lib/jammit-core/compressor.rb +214 -0
  12. data/lib/jammit-core/config.rb +82 -0
  13. data/lib/jammit-core/dependencies.rb +23 -0
  14. data/lib/jammit-core/helper.rb +77 -0
  15. data/lib/jammit-core/jst.js +1 -0
  16. data/lib/jammit-core/packager.rb +163 -0
  17. data/lib/jammit-core/routes.rb +16 -0
  18. data/lib/jammit-core/ui.rb +55 -0
  19. data/test/config/assets-broken.yml +16 -0
  20. data/test/config/assets-closure.yml +16 -0
  21. data/test/config/assets-compression-disabled.yml +16 -0
  22. data/test/config/assets-css.yml +6 -0
  23. data/test/config/assets-erb.yml +13 -0
  24. data/test/config/assets-no-java.yml +16 -0
  25. data/test/config/assets.yml +13 -0
  26. data/test/fixtures/jammed/test-closure.js +1 -0
  27. data/test/fixtures/jammed/test-datauri.css +1 -0
  28. data/test/fixtures/jammed/test-line-break.css +8 -0
  29. data/test/fixtures/jammed/test-mhtml.css +17 -0
  30. data/test/fixtures/jammed/test-uncompressed.css +36 -0
  31. data/test/fixtures/jammed/test-uncompressed.js +13 -0
  32. data/test/fixtures/jammed/test.css +1 -0
  33. data/test/fixtures/jammed/test.js +1 -0
  34. data/test/fixtures/jammed/test.jst +6 -0
  35. data/test/fixtures/jammed/test2.jst +6 -0
  36. data/test/fixtures/src/test1.css +11 -0
  37. data/test/fixtures/src/test1.js +8 -0
  38. data/test/fixtures/src/test1.jst +1 -0
  39. data/test/fixtures/src/test2.css +20 -0
  40. data/test/fixtures/src/test2.js +5 -0
  41. data/test/fixtures/src/test2.jst +5 -0
  42. data/test/fixtures/src/test_fonts.css +5 -0
  43. data/test/fixtures/tags/css_includes.html +6 -0
  44. data/test/fixtures/tags/css_individual_includes.html +3 -0
  45. data/test/fixtures/tags/css_plain_includes.html +1 -0
  46. data/test/fixtures/tags/css_print.html +6 -0
  47. data/test/public/embed/DroidSansMono.eot +0 -0
  48. data/test/public/embed/DroidSansMono.ttf +0 -0
  49. data/test/public/embed/asterisk_orange.png +0 -0
  50. data/test/public/embed/asterisk_yellow.png +0 -0
  51. data/test/test_helper.rb +44 -0
  52. data/test/unit/command_line_test.rb +28 -0
  53. data/test/unit/test_closure_compressor.rb +24 -0
  54. data/test/unit/test_compressor.rb +24 -0
  55. data/test/unit/test_configuration.rb +71 -0
  56. data/test/unit/test_in_the_wrong_directory.rb +40 -0
  57. data/test/unit/test_jammit_helpers.rb +44 -0
  58. data/test/unit/test_packager.rb +77 -0
  59. metadata +187 -0
@@ -0,0 +1,79 @@
1
+ require 'optparse'
2
+ require File.expand_path(File.dirname(__FILE__) + '/../jammit')
3
+
4
+ module Jammit
5
+
6
+ # The @CommandLine@ is able to compress, pre-package, and pre-gzip all the
7
+ # assets specified in the configuration file, in order to avoid an initial
8
+ # round of slow requests after a fresh deployment.
9
+ class CommandLine
10
+
11
+ BANNER = <<-EOS
12
+
13
+ Usage: jammit OPTIONS
14
+
15
+ Run jammit inside a Rails application to compresses all JS, CSS,
16
+ and JST according to config/assets.yml, saving the packaged
17
+ files and corresponding gzipped versions.
18
+
19
+ If you're using "embed_assets", and you wish to precompile the
20
+ MHTML stylesheet variants, you must specify the "base-url".
21
+
22
+ Options:
23
+ EOS
24
+
25
+ # The @Jammit::CommandLine@ runs from the contents of @ARGV@.
26
+ def initialize
27
+ parse_options
28
+ ensure_configuration_file
29
+ Jammit.load_configuration(@options[:config_path])
30
+ Jammit.packager.force = @options[:force]
31
+ Jammit.packager.precache_all(@options[:output_folder], @options[:base_url])
32
+ end
33
+
34
+
35
+ private
36
+
37
+ # Make sure that we have a readable configuration file. The @jammit@
38
+ # command can't run without one.
39
+ def ensure_configuration_file
40
+ config = @options[:config_path]
41
+ return true if File.exists?(config) && File.readable?(config)
42
+ puts "Could not find the asset configuration file \"#{config}\""
43
+ exit(1)
44
+ end
45
+
46
+ # Uses @OptionParser@ to grab the options: *--output*, *--config*, and
47
+ # *--base-url*...
48
+ def parse_options
49
+ @options = {
50
+ :config_path => Jammit::DEFAULT_CONFIG_PATH,
51
+ :output_folder => nil,
52
+ :base_url => nil,
53
+ :force => false
54
+ }
55
+ @option_parser = OptionParser.new do |opts|
56
+ opts.on('-o', '--output PATH', 'output folder for packages (default: "public/assets")') do |output_folder|
57
+ @options[:output_folder] = output_folder
58
+ end
59
+ opts.on('-c', '--config PATH', 'path to assets.yml (default: "config/assets.yml")') do |config_path|
60
+ @options[:config_path] = config_path
61
+ end
62
+ opts.on('-u', '--base-url URL', 'base URL for MHTML (ex: "http://example.com")') do |base_url|
63
+ @options[:base_url] = base_url
64
+ end
65
+ opts.on('-f', '--force', 'force a rebuild of all assets') do |force|
66
+ @options[:force] = force
67
+ end
68
+ opts.on_tail('-v', '--version', 'display Jammit version') do
69
+ puts "Jammit version #{Jammit::VERSION}"
70
+ exit
71
+ end
72
+ end
73
+ @option_parser.banner = BANNER
74
+ @option_parser.parse!(ARGV)
75
+ end
76
+
77
+ end
78
+
79
+ end
@@ -0,0 +1,214 @@
1
+ module Jammit
2
+
3
+ # Uses the YUI Compressor or Closure Compiler to compress JavaScript.
4
+ # Always uses YUI to compress CSS (Which means that Java must be installed.)
5
+ # Also knows how to create a concatenated JST file.
6
+ # If "embed_assets" is turned on, creates "mhtml" and "datauri" versions of
7
+ # all stylesheets, with all enabled assets inlined into the css.
8
+ class Compressor
9
+
10
+ # Mapping from extension to mime-type of all embeddable assets.
11
+ EMBED_MIME_TYPES = {
12
+ '.png' => 'image/png',
13
+ '.jpg' => 'image/jpeg',
14
+ '.jpeg' => 'image/jpeg',
15
+ '.gif' => 'image/gif',
16
+ '.tif' => 'image/tiff',
17
+ '.tiff' => 'image/tiff',
18
+ '.ttf' => 'font/truetype',
19
+ '.otf' => 'font/opentype'
20
+ }
21
+
22
+ # Font extensions for which we allow embedding:
23
+ EMBED_EXTS = EMBED_MIME_TYPES.keys
24
+ EMBED_FONTS = ['.ttf', '.otf']
25
+
26
+ # Maximum size for embeddable images (an IE8 limitation).
27
+ MAX_IMAGE_SIZE = 32 * 1024
28
+
29
+ # CSS asset-embedding regexes for URL rewriting.
30
+ EMBED_DETECTOR = /url\(['"]?([^\s)]+\.[a-z]+)(\?\d+)?['"]?\)/
31
+ EMBEDDABLE = /[\A\/]embed\//
32
+ EMBED_REPLACER = /url\(__EMBED__([^\s)]+)(\?\d+)?\)/
33
+
34
+ # MHTML file constants.
35
+ MHTML_START = "/*\r\nContent-Type: multipart/related; boundary=\"JAMMIT_MHTML_SEPARATOR\"\r\n\r\n"
36
+ MHTML_SEPARATOR = "--JAMMIT_MHTML_SEPARATOR\r\n"
37
+ MHTML_END = "*/\r\n"
38
+
39
+ # JST file constants.
40
+ JST_START = "(function(){"
41
+ JST_END = "})();"
42
+
43
+ COMPRESSORS = {
44
+ :yui => YUI::JavaScriptCompressor,
45
+ :closure => Closure::Compiler
46
+ }
47
+
48
+ DEFAULT_OPTIONS = {
49
+ :yui => {:munge => true},
50
+ :closure => {}
51
+ }
52
+
53
+ # Creating a compressor initializes the internal YUI Compressor from
54
+ # the "yui-compressor" gem, or the internal Closure Compiler from the
55
+ # "closure-compiler" gem.
56
+ def initialize
57
+ @css_compressor = YUI::CssCompressor.new(Jammit.config[:css_compressor_options] || {})
58
+ flavor = Jammit.config[:javascript_compressor] || Jammit::DEFAULT_COMPRESSOR
59
+ @options = DEFAULT_OPTIONS[flavor].merge(Jammit.config[:compressor_options] || {})
60
+ @js_compressor = COMPRESSORS[flavor].new(@options)
61
+ end
62
+
63
+ # Concatenate together a list of JavaScript paths, and pass them through the
64
+ # YUI Compressor (with munging enabled).
65
+ def compress_js(paths)
66
+ js = concatenate(paths)
67
+ Jammit.config[:compress_assets] ? @js_compressor.compress(js) : js
68
+ end
69
+
70
+ # Concatenate and compress a list of CSS stylesheets. When compressing a
71
+ # :datauri or :mhtml variant, post-processes the result to embed
72
+ # referenced assets.
73
+ def compress_css(paths, variant=nil, asset_url=nil)
74
+ css = concatenate_and_tag_assets(paths, variant)
75
+ css = @css_compressor.compress(css) if Jammit.compress_assets
76
+ case variant
77
+ when nil then return css
78
+ when :datauri then return with_data_uris(css)
79
+ when :mhtml then return with_mhtml(css, asset_url)
80
+ else raise PackageNotFound, "\"#{variant}\" is not a valid stylesheet variant"
81
+ end
82
+ end
83
+
84
+ # Compiles a single JST file by writing out a javascript that adds
85
+ # template properties to a top-level template namespace object. Adds a
86
+ # JST-compilation function to the top of the package, unless you've
87
+ # specified your own preferred function, or turned it off.
88
+ # JST templates are named with the basename of their file.
89
+ def compile_jst(paths)
90
+ namespace = Jammit.template_namespace
91
+ compiled = paths.map do |path|
92
+ template_name = File.basename(path, File.extname(path))
93
+ contents = File.read(path).gsub(/\n/, '').gsub("'", '\\\\\'')
94
+ "#{namespace}.#{template_name} = #{Jammit.template_function}('#{contents}');"
95
+ end
96
+ compiler = Jammit.config[:include_jst_script] ? File.read(Jammit::DEFAULT_JST_SCRIPT) : '';
97
+ setup_namespace = "#{namespace} = #{namespace} || {};"
98
+ [JST_START, setup_namespace, compiler, compiled, JST_END].flatten.join("\n")
99
+ end
100
+
101
+
102
+ private
103
+
104
+ # In order to support embedded assets from relative paths, we need to
105
+ # expand the paths before contatenating the CSS together and losing the
106
+ # location of the original stylesheet path. Validate the assets while we're
107
+ # at it.
108
+ def concatenate_and_tag_assets(paths, variant=nil)
109
+ stylesheets = [paths].flatten.map do |css_path|
110
+ File.read(css_path).gsub(EMBED_DETECTOR) do |url|
111
+ ipath, cpath = Pathname.new($1), Pathname.new(File.expand_path(css_path))
112
+ is_url = URI.parse($1).absolute?
113
+ is_url ? url : "url(#{construct_asset_path(ipath, cpath, variant)})"
114
+ end
115
+ end
116
+ stylesheets.join("\n")
117
+ end
118
+
119
+ # Re-write all enabled asset URLs in a stylesheet with their corresponding
120
+ # Data-URI Base-64 encoded asset contents.
121
+ def with_data_uris(css)
122
+ css.gsub(EMBED_REPLACER) do |url|
123
+ "url(\"data:#{mime_type($1)};charset=utf-8;base64,#{encoded_contents($1)}\")"
124
+ end
125
+ end
126
+
127
+ # Re-write all enabled asset URLs in a stylesheet with the MHTML equivalent.
128
+ # The newlines ("\r\n") in the following method are critical. Without them
129
+ # your MHTML will look identical, but won't work.
130
+ def with_mhtml(css, asset_url)
131
+ paths, index = {}, 0
132
+ css = css.gsub(EMBED_REPLACER) do |url|
133
+ i = paths[$1] ||= "#{index += 1}-#{File.basename($1)}"
134
+ "url(mhtml:#{asset_url}!#{i})"
135
+ end
136
+ mhtml = paths.sort.map do |path, identifier|
137
+ mime, contents = mime_type(path), encoded_contents(path)
138
+ [MHTML_SEPARATOR, "Content-Location: #{identifier}\r\n", "Content-Type: #{mime}\r\n", "Content-Transfer-Encoding: base64\r\n\r\n", contents, "\r\n"]
139
+ end
140
+ [MHTML_START, mhtml, MHTML_END, css].flatten.join('')
141
+ end
142
+
143
+ # Return a rewritten asset URL for a new stylesheet -- the asset should
144
+ # be tagged for embedding if embeddable, and referenced at the correct level
145
+ # if relative.
146
+ def construct_asset_path(asset_path, css_path, variant)
147
+ public_path = absolute_path(asset_path, css_path)
148
+ return "__EMBED__#{public_path}" if embeddable?(public_path, variant)
149
+ source = asset_path.absolute? ? asset_path.to_s : relative_path(public_path)
150
+ rewrite_asset_path(source, public_path)
151
+ end
152
+
153
+ # Get the site-absolute public path for an asset file path that may or may
154
+ # not be relative, given the path of the stylesheet that contains it.
155
+ def absolute_path(asset_pathname, css_pathname)
156
+ (asset_pathname.absolute? ?
157
+ Pathname.new(File.join(PUBLIC_ROOT, asset_pathname)) :
158
+ css_pathname.dirname + asset_pathname).cleanpath
159
+ end
160
+
161
+ # CSS assets that are referenced by relative paths, and are *not* being
162
+ # embedded, must be rewritten relative to the newly-merged stylesheet path.
163
+ def relative_path(absolute_path)
164
+ File.join('../', absolute_path.sub(PUBLIC_ROOT, ''))
165
+ end
166
+
167
+ # Similar to the AssetTagHelper's method of the same name, this will
168
+ # append the RAILS_ASSET_ID cache-buster to URLs, if it's defined.
169
+ def rewrite_asset_path(path, file_path)
170
+ asset_id = rails_asset_id(file_path)
171
+ asset_id.blank? ? path : "#{path}?#{asset_id}"
172
+ end
173
+
174
+ # Similar to the AssetTagHelper's method of the same name, this will
175
+ # determine the correct asset id for a file.
176
+ def rails_asset_id(path)
177
+ asset_id = ENV["RAILS_ASSET_ID"]
178
+ return asset_id if asset_id
179
+ File.exists?(path) ? File.mtime(path).to_i.to_s : ''
180
+ end
181
+
182
+ # An asset is valid for embedding if it exists, is less than 32K, and is
183
+ # stored somewhere inside of a folder named "embed".
184
+ # IE does not support Data-URIs larger than 32K, and you probably shouldn't
185
+ # be embedding assets that large in any case.
186
+ def embeddable?(asset_path, variant)
187
+ font = EMBED_FONTS.include?(asset_path.extname)
188
+ return false unless variant
189
+ return false unless asset_path.to_s.match(EMBEDDABLE) && asset_path.exist?
190
+ return false unless EMBED_EXTS.include?(asset_path.extname)
191
+ return false unless font || asset_path.size < MAX_IMAGE_SIZE
192
+ return false if font && variant == :mhtml
193
+ true
194
+ end
195
+
196
+ # Return the Base64-encoded contents of an asset on a single line.
197
+ def encoded_contents(asset_path)
198
+ data = File.open(asset_path, 'rb'){|f| f.read }
199
+ Base64.encode64(data).gsub(/\n/, '')
200
+ end
201
+
202
+ # Grab the mime-type of an asset, by filename.
203
+ def mime_type(asset_path)
204
+ EMBED_MIME_TYPES[File.extname(asset_path)]
205
+ end
206
+
207
+ # Concatenate together a list of asset files.
208
+ def concatenate(paths)
209
+ [paths].flatten.map {|p| File.read(p) }.join("\n")
210
+ end
211
+
212
+ end
213
+
214
+ end
@@ -0,0 +1,82 @@
1
+ module Jammit
2
+ module Config
3
+
4
+ CONFIG_TEMPLATE = Jammit::ROOT + '/assets.example.yml'
5
+
6
+ class << self
7
+
8
+ def dispatch(command, *params)
9
+ case command
10
+ when :create
11
+ create
12
+ when :set
13
+ set(params.shift, params.shift)
14
+ end
15
+ end
16
+
17
+ ##
18
+ # load configuration file
19
+ # @param {String} path
20
+ # @param {Boolean} force reloading of config file
21
+ #
22
+ def load(force=false)
23
+ raise Jammit::ConfigurationNotFound, "could not find the \"#{Jammit.config_path}\" configuration file" unless File.exists?(Jammit.config_path) && File.readable?(Jammit.config_path)
24
+ if !@config || force == true
25
+ @config = YAML.load(ERB.new(File.read(Jammit.config_path)).result)
26
+ end
27
+ @config
28
+ end
29
+
30
+ private
31
+
32
+ ##
33
+ # Write config to file
34
+ #
35
+ def write(config)
36
+ File.open(Jammit.config_path, "w") {|f|
37
+ f << config.to_yaml
38
+ }
39
+ end
40
+
41
+ ##
42
+ # If a config path is given, use that as the root, otherwise use DEFAULT_CONFIG_PATH
43
+ #
44
+ def create
45
+ if File.exists?(Jammit.config_path)
46
+ raise Jammit::AlreadyConfigured.new(" Config file #{Jammit.config_path} already exists.")
47
+ end
48
+ FileUtils.mkdir_p(File.dirname(Jammit.config_path))
49
+ FileUtils.cp(CONFIG_TEMPLATE, Jammit.config_path)
50
+
51
+ parts = Jammit.config_path.split('/')
52
+ name = parts.pop
53
+ dir = parts.pop
54
+ Jammit.ui.confirm(" create #{dir}/#{name}")
55
+ end
56
+
57
+ ##
58
+ # Set a config property in file
59
+ # @param {String} name
60
+ # @param {String} value
61
+ #
62
+ def set(name, value)
63
+ value = (value == "true") ? true : (value == "false") ? false : value
64
+
65
+ config = load(true)
66
+ option = config[name]
67
+
68
+ # careful not to clobber raexisting values if updating a hash
69
+ if value.kind_of?(Hash) && option.kind_of?(Hash)
70
+ value.keys.each do |k|
71
+ option[k] = value[k]
72
+ end
73
+ config.update(name => option)
74
+ else
75
+ config.update(name => value)
76
+ end
77
+ write(config)
78
+ Jammit.ui.confirm(" set #{name}")
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,23 @@
1
+ # Standard Library Dependencies:
2
+ require 'uri'
3
+ require 'erb'
4
+ require 'zlib'
5
+ require 'base64'
6
+ require 'pathname'
7
+ require 'fileutils'
8
+ require 'json'
9
+
10
+ # Pull in some extlib goodies
11
+ require 'extlib/class'
12
+ require 'extlib/hash'
13
+ require 'extlib/mash'
14
+ require 'extlib/inflection'
15
+
16
+ # Gem Dependencies:
17
+ require 'rubygems'
18
+ require 'yui/compressor'
19
+ require 'closure-compiler'
20
+
21
+ # Jammit Core:
22
+ require 'jammit-core/compressor'
23
+ require 'jammit-core/packager'
@@ -0,0 +1,77 @@
1
+ module Jammit
2
+
3
+ # The Jammit::Helper module, which is made available to every view, provides
4
+ # helpers for writing out HTML tags for asset packages. In development you
5
+ # get the ordered list of source files -- in any other environment, a link
6
+ # to the cached packages.
7
+ module Helper
8
+
9
+ DATA_URI_START = "<!--[if (!IE)|(gte IE 8)]><!-->"
10
+ DATA_URI_END = "<!--<![endif]-->"
11
+ MHTML_START = "<!--[if lte IE 7]>"
12
+ MHTML_END = "<![endif]-->"
13
+
14
+ # If embed_assets is turned on, writes out links to the Data-URI and MHTML
15
+ # versions of the stylesheet package, otherwise the package is regular
16
+ # compressed CSS, and in development the stylesheet URLs are passed verbatim.
17
+ def include_stylesheets(*packages)
18
+ options = packages.extract_options!
19
+ return individual_stylesheets(packages, options) unless Jammit.package_assets
20
+ disabled = (options.delete(:embed_assets) == false) || (options.delete(:embed_images) == false)
21
+ return packaged_stylesheets(packages, options) if disabled || !Jammit.embed_assets
22
+ return embedded_image_stylesheets(packages, options)
23
+ end
24
+
25
+ # Writes out the URL to the bundled and compressed javascript package,
26
+ # except in development, where it references the individual scripts.
27
+ def include_javascripts(*packages)
28
+ tags = packages.map do |pack|
29
+ Jammit.package_assets ? Jammit.asset_url(pack, :js) : Jammit.packager.individual_urls(pack.to_sym, :js)
30
+ end
31
+ javascript_include_tag(tags.flatten)
32
+ end
33
+
34
+ # Writes out the URL to the concatenated and compiled JST file -- we always
35
+ # have to pre-process it, even in development.
36
+ def include_templates(*packages)
37
+ javascript_include_tag(packages.map {|pack| Jammit.asset_url(pack, :jst) })
38
+ end
39
+
40
+
41
+ private
42
+
43
+ # HTML tags, in order, for all of the individual stylesheets.
44
+ def individual_stylesheets(packages, options)
45
+ tags_with_options(packages, options) {|p| Jammit.packager.individual_urls(p.to_sym, :css) }
46
+ end
47
+
48
+ # HTML tags for the stylesheet packages.
49
+ def packaged_stylesheets(packages, options)
50
+ tags_with_options(packages, options) {|p| Jammit.asset_url(p, :css) }
51
+ end
52
+
53
+ # HTML tags for the 'datauri', and 'mhtml' versions of the packaged
54
+ # stylesheets, using conditional comments to load the correct variant.
55
+ def embedded_image_stylesheets(packages, options)
56
+ datauri_tags = tags_with_options(packages, options) {|p| Jammit.asset_url(p, :css, :datauri) }
57
+ ie_tags = Jammit.mhtml_enabled ?
58
+ tags_with_options(packages, options) {|p| Jammit.asset_url(p, :css, :mhtml) } :
59
+ packaged_stylesheets(packages, options)
60
+ [DATA_URI_START, datauri_tags, DATA_URI_END, MHTML_START, ie_tags, MHTML_END].join("\n")
61
+ end
62
+
63
+ # Generate the stylesheet tags for a batch of packages, with options, by
64
+ # yielding each package to a block.
65
+ def tags_with_options(packages, options)
66
+ packages = packages.dup
67
+ packages.map! {|package| yield package }.flatten!
68
+ packages.push(options) unless options.empty?
69
+ stylesheet_link_tag(*packages)
70
+ end
71
+
72
+ end
73
+
74
+ end
75
+
76
+ # Include the Jammit asset helpers in all views, a-la ApplicationHelper.
77
+ ::ActionView::Base.send(:include, Jammit::Helper)