sinatra-assetpack 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. data/.gitignore +1 -0
  2. data/Gemfile +2 -0
  3. data/HISTORY.md +45 -0
  4. data/README.md +248 -0
  5. data/Rakefile +3 -0
  6. data/example/.gitignore +1 -0
  7. data/example/Rakefile +7 -0
  8. data/example/app.rb +28 -0
  9. data/example/app/css/test.sass +11 -0
  10. data/example/app/images/icon.png +0 -0
  11. data/example/app/js/app.js +3 -0
  12. data/example/app/js/vendor/jquery.js +2 -0
  13. data/example/app/js/vendor/jquery.plugin.js +2 -0
  14. data/example/app/js/vendor/underscore.js +2 -0
  15. data/example/views/index.erb +26 -0
  16. data/lib/sinatra/assetpack.rb +55 -0
  17. data/lib/sinatra/assetpack/buster_helpers.rb +21 -0
  18. data/lib/sinatra/assetpack/class_methods.rb +68 -0
  19. data/lib/sinatra/assetpack/compressor.rb +109 -0
  20. data/lib/sinatra/assetpack/configurator.rb +15 -0
  21. data/lib/sinatra/assetpack/css.rb +35 -0
  22. data/lib/sinatra/assetpack/hasharray.rb +70 -0
  23. data/lib/sinatra/assetpack/helpers.rb +48 -0
  24. data/lib/sinatra/assetpack/html_helpers.rb +17 -0
  25. data/lib/sinatra/assetpack/image.rb +37 -0
  26. data/lib/sinatra/assetpack/options.rb +223 -0
  27. data/lib/sinatra/assetpack/package.rb +111 -0
  28. data/lib/sinatra/assetpack/rake.rb +23 -0
  29. data/lib/sinatra/assetpack/version.rb +7 -0
  30. data/sinatra-assetpack.gemspec +23 -0
  31. data/test/app/.gitignore +1 -0
  32. data/test/app/Rakefile +7 -0
  33. data/test/app/app.rb +51 -0
  34. data/test/app/app/css/js2c.css +494 -0
  35. data/test/app/app/css/screen.sass +9 -0
  36. data/test/app/app/css/sqwishable.css +7 -0
  37. data/test/app/app/css/style.css +2 -0
  38. data/test/app/app/images/background.jpg +1 -0
  39. data/test/app/app/images/email.png +0 -0
  40. data/test/app/app/js/hello.js +1 -0
  41. data/test/app/app/js/hi.coffee +2 -0
  42. data/test/app/app/views/index.haml +1 -0
  43. data/test/build_test.rb +20 -0
  44. data/test/cache_test.rb +10 -0
  45. data/test/helpers_test.rb +23 -0
  46. data/test/img_test.rb +13 -0
  47. data/test/options_test.rb +17 -0
  48. data/test/order_test.rb +21 -0
  49. data/test/preproc_test.rb +23 -0
  50. data/test/simplecss_test.rb +16 -0
  51. data/test/sqwish_test.rb +29 -0
  52. data/test/test_helper.rb +38 -0
  53. data/test/unit_test.rb +96 -0
  54. data/test/yui_test.rb +22 -0
  55. metadata +200 -0
@@ -0,0 +1,21 @@
1
+ module Sinatra
2
+ module AssetPack
3
+ module BusterHelpers
4
+ extend self
5
+ # Returns the cache buster suffix for given file(s).
6
+ # This implementation somewhat obfuscates the mtime to not reveal deployment dates.
7
+ def cache_buster_hash(*files)
8
+ i = files.map { |f| File.mtime(f).to_i }.max
9
+ (i * 4567).to_s.reverse[0...6]
10
+ end
11
+
12
+ # Adds a cache buster for the given path.
13
+ #
14
+ # add_cache_buster('/images/email.png', '/var/www/x/public/images/email.png')
15
+ #
16
+ def add_cache_buster(path, *files)
17
+ path.gsub(/(\.[^.]+)$/) { |ext| ".#{cache_buster_hash *files}#{ext}" }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,68 @@
1
+ module Sinatra
2
+ module AssetPack
3
+ # Class methods that will be given to the Sinatra application.
4
+ module ClassMethods
5
+ # Sets asset options, or gets them
6
+ def assets(&blk)
7
+ @options ||= Options.new(self, &blk)
8
+ self.assets_initialize! if block_given?
9
+
10
+ @options
11
+ end
12
+
13
+ def assets_initialize!
14
+ add_compressed_routes!
15
+ add_individual_routes!
16
+ end
17
+
18
+ # Add routes for the compressed versions
19
+ def add_compressed_routes!
20
+ assets.packages.each do |name, package|
21
+ get package.route_regex do
22
+ content_type package.type
23
+ last_modified package.mtime
24
+
25
+ settings.assets.cache[package.hash] ||= package.minify
26
+ end
27
+ end
28
+ end
29
+
30
+ # Add the routes for the individual files.
31
+ def add_individual_routes!
32
+ assets.served.each do |path, from|
33
+ get "/#{path}/*".squeeze('/') do |file|
34
+ fmt = File.extname(file)[1..-1]
35
+
36
+ # Sanity checks
37
+ pass unless AssetPack.supported_formats.include?(fmt)
38
+ fn = asset_path_for(file, from) or pass
39
+
40
+ # Send headers
41
+ content_type fmt.to_sym
42
+ last_modified File.mtime(fn).to_i
43
+
44
+ format = File.extname(fn)[1..-1]
45
+
46
+ if AssetPack.supported_formats.include?(format)
47
+ # It's a raw file, just send it
48
+ not_found unless format == fmt
49
+
50
+ if fmt == 'css'
51
+ asset_filter_css File.read(fn)
52
+ else
53
+ send_file fn
54
+ end
55
+ else
56
+ # Dynamic file
57
+ not_found unless AssetPack.tilt_formats[format] == fmt
58
+ out = render format.to_sym, File.read(fn)
59
+ out = asset_filter_css(out) if fmt == 'css'
60
+ out
61
+ end
62
+ end
63
+ end
64
+
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,109 @@
1
+ module Sinatra
2
+ module AssetPack
3
+ module Compressor
4
+ extend self
5
+
6
+ # Compresses a given string.
7
+ #
8
+ # compress File.read('x.js'), :js, :jsmin
9
+ #
10
+ def compress(str, type, engine=nil, options={})
11
+ engine ||= 'jsmin' if type == :js
12
+ engine ||= 'simple' if type == :css
13
+
14
+ key = :"#{type}/#{engine}"
15
+ meth = compressors[key]
16
+ return str unless meth
17
+
18
+ meth[str, options]
19
+ end
20
+
21
+ def compressors
22
+ @compressors ||= {
23
+ :'js/jsmin' => method(:jsmin),
24
+ :'js/yui' => method(:yui_js),
25
+ :'js/closure' => method(:closure_js),
26
+ :'css/sass' => method(:sass),
27
+ :'css/yui' => method(:yui_css),
28
+ :'css/simple' => method(:simple_css),
29
+ :'css/sqwish' => method(:sqwish_css)
30
+ }
31
+ end
32
+
33
+ # =====================================================================
34
+ # Compressors
35
+
36
+ def jsmin(str, options={})
37
+ require 'jsmin'
38
+ JSMin.minify str
39
+ end
40
+
41
+ def sass(str, options={})
42
+ Tilt.new("scss", {:style => :compressed}) { str }.render
43
+ rescue LoadError
44
+ simple_css str
45
+ end
46
+
47
+ def yui_css(str, options={})
48
+ require 'yui/compressor'
49
+ YUI::CssCompressor.new.compress(str)
50
+ rescue Errno::ENOENT
51
+ sass str
52
+ end
53
+
54
+ def yui_js(str, options={})
55
+ require 'yui/compressor'
56
+ YUI::JavaScriptCompressor.new(options).compress(str)
57
+ rescue LoadError
58
+ jsmin str
59
+ end
60
+
61
+ def simple_css(str, options={})
62
+ str.gsub! /[ \r\n\t]+/m, ' '
63
+ str.gsub! %r{ *([;\{\},:]) *}, '\1'
64
+ end
65
+
66
+ def sqwish_css(str, options={})
67
+ cmd = "#{sqwish_bin} %f "
68
+ cmd += "--strict" if options[:strict]
69
+
70
+ _, input = sys :css, str, cmd
71
+ output = input.gsub(/\.css/, '.min.css')
72
+
73
+ File.read(output)
74
+ rescue => e
75
+ simple_css str
76
+ end
77
+
78
+ def sqwish_bin
79
+ ENV['SQWISH_PATH'] || "sqwish"
80
+ end
81
+
82
+ def closure_js(str, options={})
83
+ require 'net/http'
84
+ require 'uri'
85
+
86
+ response = Net::HTTP.post_form(URI.parse('http://closure-compiler.appspot.com/compile'), {
87
+ 'js_code' => str,
88
+ 'compilation_level' => options[:level] || "ADVANCED_OPTIMIZATIONS",
89
+ 'output_format' => 'text',
90
+ 'output_info' => 'compiled_code'
91
+ })
92
+
93
+ response.body
94
+ end
95
+
96
+ # For others
97
+ def sys(type, str, cmd)
98
+ t = Tempfile.new ['', ".#{type}"]
99
+ t.write(str)
100
+ t.close
101
+
102
+ output = `#{cmd.gsub('%f', t.path)}`
103
+ FileUtils.rm t
104
+
105
+ [output, t.path]
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,15 @@
1
+ module Sinatra
2
+ module AssetPack
3
+ module Configurator
4
+ def attrib(name)
5
+ define_method(:"#{name}") { |*a|
6
+ value = a.first
7
+ self.instance_variable_set :"@#{name}", value unless value.nil?
8
+ self.instance_variable_get :"@#{name}"
9
+ }
10
+
11
+ alias_method(:"#{name}=", :"#{name}")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,35 @@
1
+ module Sinatra
2
+ module AssetPack
3
+ module Css
4
+ def self.preproc(str, assets)
5
+ str.gsub(/url\(["']?(.*?)["']?\)/) { |url|
6
+ file, options = $1.split('?')
7
+ local = assets.local_file_for file
8
+
9
+ url = if local
10
+ if options.to_s.include?('embed')
11
+ to_data_uri(local)
12
+ else
13
+ BusterHelpers.add_cache_buster(file, local)
14
+ end
15
+ else
16
+ url
17
+ end
18
+
19
+ "url(#{url})"
20
+ }
21
+ end
22
+
23
+ def self.to_data_uri(file)
24
+ require 'base64'
25
+
26
+ data = File.read(file)
27
+ ext = File.extname(file)
28
+ mime = Sinatra::Base.mime_type(ext)
29
+ b64 = Base64.encode64(data).gsub("\n", '')
30
+
31
+ "data:#{mime};base64,#{b64}"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,70 @@
1
+ module Sinatra
2
+ module AssetPack
3
+ # Class: HashArray
4
+ # A stopgap solution to Ruby 1.8's lack of ordered hashes.
5
+ #
6
+ # A HashArray, for all intents and purposes, acts like an array. However, the
7
+ # common stuff are overloaded to work with hashes.
8
+ #
9
+ # ## Basic usage
10
+ #
11
+ # #### Creating
12
+ # You can create a HashArray by passing it an array.
13
+ #
14
+ # dict = HashArray.new([
15
+ # { :good_morning => "Bonjour" },
16
+ # { :goodbye => "Au revoir" },
17
+ # { :good_evening => "Bon nuit" }
18
+ # ])
19
+ #
20
+ # #### Converting
21
+ # You may also use it like so:
22
+ #
23
+ # letters = [ { :a => "Aye"}, { :b => "Bee" } ].to_hash_array
24
+ #
25
+ # #### Iterating
26
+ # Now you can use the typical enumerator functions:
27
+ #
28
+ # dict.each do |(key, value)|
29
+ # puts "#{key} is #{value}"
30
+ # end
31
+ #
32
+ # #=> :good_morning is "Bonjour"
33
+ # # :goodbye is "Au revoir"
34
+ # # :good_evening is "Bon nuit"
35
+ #
36
+ class HashArray < Array
37
+ def self.[](*arr)
38
+ new arr.each_slice(2).map { |(k, v)| Hash[k, v] }
39
+ end
40
+
41
+ # Works like Hash#each.
42
+ #
43
+ # By extension, methods that rely on #each (like #inject) will work
44
+ # as intended.
45
+ #
46
+ def each(&block)
47
+ super { |hash| yield hash.to_a.flatten }
48
+ end
49
+
50
+ # Works like Hash#map.
51
+ def map(&block)
52
+ super { |hash| yield hash.to_a.flatten }
53
+ end
54
+
55
+ # Works like Hash#values.
56
+ def values
57
+ inject([]) { |a, (k, v)| a << v; a }
58
+ end
59
+
60
+ # Returns everything as a hash.
61
+ def to_hash
62
+ inject({}) { |hash, (k, v)| hash[k] = v; hash }
63
+ end
64
+
65
+ def keys
66
+ inject([]) { |a, (k, v)| a << k; a }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,48 @@
1
+ module Sinatra
2
+ module AssetPack
3
+ module Helpers
4
+ def css(name, options={})
5
+ show_asset_pack :css, name, options
6
+ end
7
+
8
+ def js(name, options={})
9
+ show_asset_pack :js, name, options
10
+ end
11
+
12
+ def img(src, options={})
13
+ attrs = { :src => src }.merge(options)
14
+
15
+ local = settings.assets.local_file_for src
16
+ if local
17
+ i = Image.new(local)
18
+ attrs[:src] = BusterHelpers.add_cache_buster(src, local)
19
+ if i.dimensions?
20
+ attrs[:width] ||= i.width
21
+ attrs[:height] ||= i.height
22
+ end
23
+ end
24
+
25
+ "<img#{HtmlHelpers.kv attrs} />"
26
+ end
27
+
28
+ def show_asset_pack(type, name, options={})
29
+ pack = settings.assets.packages["#{name}.#{type}"]
30
+ return "" unless pack
31
+
32
+ if settings.production?
33
+ pack.to_production_html options
34
+ else
35
+ pack.to_development_html options
36
+ end
37
+ end
38
+
39
+ def asset_filter_css(str)
40
+ Css.preproc str, settings.assets
41
+ end
42
+
43
+ def asset_path_for(file, from)
44
+ settings.assets.dyn_local_file_for file, from
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,17 @@
1
+ module Sinatra
2
+ module AssetPack
3
+ module HtmlHelpers
4
+ extend self
5
+
6
+ def e(str)
7
+ re = Rack::Utils.escape_html str
8
+ re = re.gsub("&#x2F;", '/') # Rack sometimes insists on munging slashes in Ruby 1.8.
9
+ re
10
+ end
11
+
12
+ def kv(hash)
13
+ hash.map { |k, v| " #{e k}='#{e v}'" }.join('')
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ module Sinatra
2
+ module AssetPack
3
+ class Image
4
+ def initialize(file)
5
+ @file = file
6
+ end
7
+
8
+ def dimensions
9
+ return @dimensions unless @dimensions.nil?
10
+
11
+ _, _, dim = `identify "#{@file}"`.split(' ')
12
+ w, h = dim.split('x')
13
+
14
+ if w.to_i != 0 && h.to_i != 0
15
+ @dimensions = [w.to_i, h.to_i]
16
+ else
17
+ @dimensions = false
18
+ end
19
+
20
+ rescue => e
21
+ @dimensions = false
22
+ end
23
+
24
+ def dimensions?
25
+ !! dimensions
26
+ end
27
+
28
+ def width
29
+ dimensions? && dimensions[0]
30
+ end
31
+
32
+ def height
33
+ dimensions? && dimensions[1]
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,223 @@
1
+ module Sinatra
2
+ module AssetPack
3
+ # Assets.
4
+ #
5
+ # == Common usage
6
+ #
7
+ # SinatraApp.assets {
8
+ # # dsl stuff here
9
+ # }
10
+ #
11
+ # a = SinatraApp.assets
12
+ #
13
+ # Getting options:
14
+ #
15
+ # a.js_compression
16
+ # a.output_path
17
+ #
18
+ # Served:
19
+ #
20
+ # a.served # { '/js' => '/var/www/project/app/js', ... }
21
+ # # (URL path => local path)
22
+ #
23
+ # Packages:
24
+ #
25
+ # a.packages # { 'app.css' => #<Package>, ... }
26
+ # # (name.type => package instance)
27
+ #
28
+ # Build:
29
+ #
30
+ # a.build! { |path| puts "Building #{path}" }
31
+ #
32
+ # Lookup:
33
+ #
34
+ # a.local_path_for('/images/bg.gif')
35
+ # a.served?('/images/bg.gif')
36
+ #
37
+ # a.glob('/js/*.js', '/js/vendor/**/*.js')
38
+ # # Returns a HashArray of (local => remote)
39
+ #
40
+ class Options
41
+ extend Configurator
42
+
43
+ def initialize(app, &blk)
44
+ @app = app
45
+ @js_compression = :jsmin
46
+ @css_compression = :simple
47
+ @output_path = app.public
48
+
49
+ @js_compression_options = Hash.new
50
+ @css_compression_options = Hash.new
51
+
52
+ reset!
53
+
54
+ # Defaults!
55
+ serve '/css', :from => 'app/css'
56
+ serve '/js', :from => 'app/js'
57
+ serve '/images', :from => 'app/images'
58
+
59
+ instance_eval &blk if block_given?
60
+ end
61
+
62
+ # =====================================================================
63
+ # DSL methods
64
+
65
+ def serve(path, options={})
66
+ raise Error unless options[:from]
67
+ return unless File.directory?(File.join(app.root, options[:from]))
68
+
69
+ @served[path] = options[:from]
70
+ end
71
+
72
+ # Undo defaults.
73
+ def reset!
74
+ @served = Hash.new
75
+ @packages = Hash.new
76
+ end
77
+
78
+ # Adds some JS packages.
79
+ #
80
+ # js :foo, '/js', [ '/js/vendor/jquery.*.js' ]
81
+ #
82
+ def js(name, path, files=[])
83
+ @packages["#{name}.js"] = Package.new(self, name, :js, path, files)
84
+ end
85
+
86
+ # Adds some CSS packages.
87
+ #
88
+ # css :app, '/css', [ '/css/screen.css' ]
89
+ #
90
+ def css(name, path, files=[])
91
+ @packages["#{name}.css"] = Package.new(self, name, :css, path, files)
92
+ end
93
+
94
+ attr_reader :app # Sinatra::Base instance
95
+ attr_reader :packages # Hash, keys are "foo.js", values are Packages
96
+ attr_reader :served # Hash, paths to be served.
97
+ # Key is URI path, value is local path
98
+
99
+ attrib :js_compression # Symbol, compression method for JS
100
+ attrib :css_compression # Symbol, compression method for CSS
101
+ attrib :output_path # '/public'
102
+
103
+ attrib :js_compression_options # Hash
104
+ attrib :css_compression_options # Hash
105
+
106
+ # =====================================================================
107
+ # Stuff
108
+
109
+ attr_reader :served
110
+
111
+ def build!(&blk)
112
+ session = Rack::Test::Session.new app
113
+
114
+ packages.each { |_, pack|
115
+ out = session.get(pack.path).body
116
+
117
+ write pack.path, out, &blk
118
+ write pack.production_path, out, &blk
119
+ }
120
+
121
+ files.each { |path, local|
122
+ out = session.get(path).body
123
+ write path, out, &blk
124
+ write BusterHelpers.add_cache_buster(path, local), out, &blk
125
+ }
126
+ end
127
+
128
+ def served?(path)
129
+ !! local_file_for(path)
130
+ end
131
+
132
+ # Returns the local file for a given URI path.
133
+ # Returns nil if a file is not found.
134
+ def local_file_for(path)
135
+ path = path.squeeze('/')
136
+
137
+ uri, local = served.detect { |uri, local| path[0...uri.size] == uri }
138
+
139
+ if local
140
+ path = path[uri.size..-1]
141
+ path = File.join app.root, local, path
142
+
143
+ path if File.exists?(path)
144
+ end
145
+ end
146
+
147
+ # Returns the local file for a given URI path. (for dynamic files)
148
+ # Returns nil if a file is not found.
149
+ # TODO: consolidate with local_file_for
150
+ def dyn_local_file_for(file, from)
151
+ # Remove extension
152
+ file = $1 if file =~ /^(.*)(\.[^\.]+)$/
153
+
154
+ # Remove cache-buster (/js/app.28389.js => /js/app)
155
+ file = $1 if file =~ /^(.*)\.[0-9]+$/
156
+
157
+ Dir[File.join(app.root, from, "#{file}.*")].first
158
+ end
159
+
160
+ # Writes `public/#{path}` based on contents of `output`.
161
+ def write(path, output)
162
+ require 'fileutils'
163
+
164
+ path = File.join(@output_path, path)
165
+ yield path if block_given?
166
+
167
+ FileUtils.mkdir_p File.dirname(path)
168
+ File.open(path, 'w') { |f| f.write output }
169
+ end
170
+
171
+ # Returns the files as a hash.
172
+ def files(match=nil)
173
+ # All
174
+ # A buncha tuples
175
+ tuples = @served.map { |prefix, local_path|
176
+ path = File.expand_path(File.join(@app.root, local_path))
177
+ spec = File.join(path, '**', '*')
178
+
179
+ Dir[spec].map { |f|
180
+ [ to_uri(f, prefix, path), f ] unless File.directory?(f)
181
+ }
182
+ }.flatten.compact
183
+
184
+ Hash[*tuples]
185
+ end
186
+
187
+ # Returns an array of URI paths of those matching given globs.
188
+ def glob(*match)
189
+ tuples = match.map { |spec|
190
+ paths = files.keys.select { |f| File.fnmatch?(spec, f) }.sort
191
+ paths.map { |key| [key, files[key]] }
192
+ }
193
+
194
+ HashArray[*tuples.flatten]
195
+ end
196
+
197
+ def cache
198
+ @cache ||= Hash.new
199
+ end
200
+
201
+ def reset_cache
202
+ @cache = nil && cache
203
+ end
204
+
205
+ private
206
+ # Returns a URI for a given file
207
+ # path = '/projects/x/app/css'
208
+ # to_uri('/projects/x/app/css/file.sass', '/styles', path) => '/styles/file.css'
209
+ #
210
+ def to_uri(f, prefix, path)
211
+ fn = (prefix + f.gsub(path, '')).squeeze('/')
212
+
213
+ # Switch the extension ('x.sass' => 'x.css')
214
+ file_ext = File.extname(fn).to_s[1..-1]
215
+ out_ext = AssetPack.tilt_formats[file_ext]
216
+
217
+ fn = fn.gsub(/\.#{file_ext}$/, ".#{out_ext}") if file_ext && out_ext
218
+
219
+ fn
220
+ end
221
+ end
222
+ end
223
+ end