roda 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +34 -0
  3. data/README.rdoc +18 -13
  4. data/Rakefile +8 -0
  5. data/doc/conventions.rdoc +163 -0
  6. data/doc/release_notes/1.1.0.txt +226 -0
  7. data/lib/roda.rb +51 -22
  8. data/lib/roda/plugins/assets.rb +613 -0
  9. data/lib/roda/plugins/caching.rb +215 -0
  10. data/lib/roda/plugins/chunked.rb +278 -0
  11. data/lib/roda/plugins/error_email.rb +112 -0
  12. data/lib/roda/plugins/flash.rb +3 -3
  13. data/lib/roda/plugins/hooks.rb +1 -1
  14. data/lib/roda/plugins/indifferent_params.rb +3 -3
  15. data/lib/roda/plugins/middleware.rb +3 -8
  16. data/lib/roda/plugins/multi_route.rb +110 -18
  17. data/lib/roda/plugins/not_allowed.rb +3 -3
  18. data/lib/roda/plugins/path.rb +38 -0
  19. data/lib/roda/plugins/render.rb +18 -16
  20. data/lib/roda/plugins/render_each.rb +0 -2
  21. data/lib/roda/plugins/streaming.rb +1 -2
  22. data/lib/roda/plugins/view_subdirs.rb +7 -1
  23. data/lib/roda/version.rb +1 -1
  24. data/spec/assets/css/app.scss +1 -0
  25. data/spec/assets/css/no_access.css +1 -0
  26. data/spec/assets/css/raw.css +1 -0
  27. data/spec/assets/js/head/app.js +1 -0
  28. data/spec/integration_spec.rb +95 -3
  29. data/spec/matchers_spec.rb +2 -2
  30. data/spec/plugin/assets_spec.rb +413 -0
  31. data/spec/plugin/caching_spec.rb +335 -0
  32. data/spec/plugin/chunked_spec.rb +182 -0
  33. data/spec/plugin/default_headers_spec.rb +6 -5
  34. data/spec/plugin/error_email_spec.rb +76 -0
  35. data/spec/plugin/multi_route_spec.rb +120 -0
  36. data/spec/plugin/not_allowed_spec.rb +14 -3
  37. data/spec/plugin/path_spec.rb +29 -0
  38. data/spec/plugin/render_each_spec.rb +6 -1
  39. data/spec/plugin/symbol_matchers_spec.rb +7 -2
  40. data/spec/request_spec.rb +10 -0
  41. data/spec/response_spec.rb +47 -0
  42. data/spec/views/about.erb +1 -0
  43. data/spec/views/about.str +1 -0
  44. data/spec/views/content-yield.erb +1 -0
  45. data/spec/views/home.erb +2 -0
  46. data/spec/views/home.str +2 -0
  47. data/spec/views/layout-alternative.erb +2 -0
  48. data/spec/views/layout-yield.erb +3 -0
  49. data/spec/views/layout.erb +2 -0
  50. data/spec/views/layout.str +2 -0
  51. metadata +57 -2
data/lib/roda.rb CHANGED
@@ -10,6 +10,7 @@ class Roda
10
10
  class RodaError < StandardError; end
11
11
 
12
12
  if defined?(RUBY_ENGINE) && RUBY_ENGINE != 'ruby'
13
+ # :nocov:
13
14
  # A thread safe cache class, offering only #[] and #[]= methods,
14
15
  # each protected by a mutex. Used on non-MRI where Hash is not
15
16
  # thread safe.
@@ -30,6 +31,7 @@ class Roda
30
31
  @mutex.synchronize{@hash[key] = value}
31
32
  end
32
33
  end
34
+ # :nocov:
33
35
  else
34
36
  # Hashes are already thread-safe in MRI, due to the GVL, so they
35
37
  # can safely be used as a cache.
@@ -51,9 +53,10 @@ class Roda
51
53
  @roda_class = ::Roda
52
54
  end
53
55
 
54
- @builder = ::Rack::Builder.new
56
+ @app = nil
55
57
  @middleware = []
56
58
  @opts = {}
59
+ @route_block = nil
57
60
 
58
61
  # Module in which all Roda plugins should be stored. Also contains logic for
59
62
  # registering and loading plugins.
@@ -86,6 +89,8 @@ class Roda
86
89
  # Methods are put into a plugin so future plugins can easily override
87
90
  # them and call super to get the default behavior.
88
91
  module Base
92
+ SESSION_KEY = 'rack.session'.freeze
93
+
89
94
  # Class methods for the Roda class.
90
95
  module ClassMethods
91
96
  # The rack application that this class uses.
@@ -94,6 +99,9 @@ class Roda
94
99
  # The settings/options hash for the current class.
95
100
  attr_reader :opts
96
101
 
102
+ # The route block that this class uses.
103
+ attr_reader :route_block
104
+
97
105
  # Call the internal rack application with the given environment.
98
106
  # This allows the class itself to be used as a rack application.
99
107
  # However, for performance, it's better to use #app to get direct
@@ -122,17 +130,14 @@ class Roda
122
130
  request_module{define_method(:"match_#{key}", &block)}
123
131
  end
124
132
 
125
- # When inheriting Roda, setup a new rack app builder, copy the
126
- # default middleware and opts into the subclass, and set the
127
- # request and response classes in the subclasses to be subclasses
128
- # of the request and responses classes in the parent class. This
129
- # makes it so child classes inherit plugins from their parent,
130
- # but using plugins in child classes does not affect the parent.
133
+ # When inheriting Roda, copy the shared data into the subclass,
134
+ # and setup the request and response subclasses.
131
135
  def inherited(subclass)
132
136
  super
133
- subclass.instance_variable_set(:@builder, ::Rack::Builder.new)
134
137
  subclass.instance_variable_set(:@middleware, @middleware.dup)
135
138
  subclass.instance_variable_set(:@opts, opts.dup)
139
+ subclass.instance_variable_set(:@route_block, @route_block)
140
+ subclass.send(:build_rack_app)
136
141
 
137
142
  request_class = Class.new(self::RodaRequest)
138
143
  request_class.roda_class = subclass
@@ -235,9 +240,8 @@ class Roda
235
240
  # This should only be called once per class, and if called multiple
236
241
  # times will overwrite the previous routing.
237
242
  def route(&block)
238
- @middleware.each{|a, b| @builder.use(*a, &b)}
239
- @builder.run lambda{|env| new.call(env, &block)}
240
- @app = @builder.to_app
243
+ @route_block = block
244
+ build_rack_app
241
245
  end
242
246
 
243
247
  # A new thread safe cache instance. This is a method so it can be
@@ -252,10 +256,21 @@ class Roda
252
256
  # Roda.use Rack::Session::Cookie, :secret=>ENV['secret']
253
257
  def use(*args, &block)
254
258
  @middleware << [args, block]
259
+ build_rack_app
255
260
  end
256
261
 
257
262
  private
258
263
 
264
+ # Build the rack app to use
265
+ def build_rack_app
266
+ if block = @route_block
267
+ builder = Rack::Builder.new
268
+ @middleware.each{|a, b| builder.use(*a, &b)}
269
+ builder.run lambda{|env| allocate.call(env, &block)}
270
+ @app = builder.to_app
271
+ end
272
+ end
273
+
259
274
  # Backbone of the request_module and response_module support.
260
275
  def module_include(type, mod)
261
276
  if type == :response
@@ -270,7 +285,9 @@ class Roda
270
285
  raise RodaError, "can't provide both argument and block to response_module" if block_given?
271
286
  klass.send(:include, mod)
272
287
  else
273
- unless mod = instance_variable_get(iv)
288
+ if instance_variable_defined?(iv)
289
+ mod = instance_variable_get(iv)
290
+ else
274
291
  mod = instance_variable_set(iv, Module.new)
275
292
  klass.send(:include, mod)
276
293
  end
@@ -284,8 +301,6 @@ class Roda
284
301
 
285
302
  # Instance methods for the Roda class.
286
303
  module InstanceMethods
287
- SESSION_KEY = 'rack.session'.freeze
288
-
289
304
  # Create a request and response of the appopriate
290
305
  # class, the instance_exec the route block with
291
306
  # the request, handling any halts. This is not usually
@@ -930,13 +945,31 @@ class Roda
930
945
  end
931
946
 
932
947
  # Return the rack response array of status, headers, and body
933
- # for the current response. Example:
948
+ # for the current response. If the status has not been set,
949
+ # uses a 200 status if the body has been written to, otherwise
950
+ # uses a 404 status. Adds the Content-Length header to the
951
+ # size of the response body.
934
952
  #
935
- # response.finish # => [200, {'Content-Type'=>'text/html'}, []]
953
+ # Example:
954
+ #
955
+ # response.finish
956
+ # # => [200,
957
+ # # {'Content-Type'=>'text/html', 'Content-Length'=>'0'},
958
+ # # []]
936
959
  def finish
937
960
  b = @body
938
961
  s = (@status ||= b.empty? ? 404 : 200)
939
- [s, @headers, b]
962
+ h = @headers
963
+ h[CONTENT_LENGTH] = @length.to_s
964
+ [s, h, b]
965
+ end
966
+
967
+ # Return the rack response array using a given body. Assumes a
968
+ # 200 response status unless status has been explicitly set,
969
+ # and doesn't add the Content-Length header or use the existing
970
+ # body.
971
+ def finish_with_body(body)
972
+ [@status || 200, @headers, body]
940
973
  end
941
974
 
942
975
  # Set the Location header to the given path, and the status
@@ -957,16 +990,12 @@ class Roda
957
990
  ::Rack::Utils.set_cookie_header!(@headers, key, value)
958
991
  end
959
992
 
960
- # Write to the response body. Updates Content-Length header
961
- # with the size of the string written. Returns nil. Example:
993
+ # Write to the response body. Returns nil.
962
994
  #
963
995
  # response.write('foo')
964
- # response['Content-Length'] # =>'3'
965
996
  def write(str)
966
997
  s = str.to_s
967
-
968
998
  @length += s.bytesize
969
- @headers[CONTENT_LENGTH] = @length.to_s
970
999
  @body << s
971
1000
  nil
972
1001
  end
@@ -0,0 +1,613 @@
1
+ class Roda
2
+ module RodaPlugins
3
+ # The assets plugin adds support for rendering your CSS and javascript
4
+ # asset files on the fly in development, and compiling them
5
+ # to a single, compressed file in production.
6
+ #
7
+ # This uses the render plugin for rendering the assets, and the render
8
+ # plugin uses tilt internally, so you can use any template engine
9
+ # supported by tilt for you assets. Tilt ships with support for
10
+ # the following asset template engines, assuming the necessary libaries
11
+ # are installed:
12
+ #
13
+ # css :: Less, Sass, Scss
14
+ # js :: CoffeeScript
15
+ #
16
+ # == Usage
17
+ #
18
+ # When loading the plugin, use the :css and :js options
19
+ # to set the source file(s) to use for CSS and javascript assets:
20
+ #
21
+ # plugin :assets, :css => 'some_file.scss', :js => 'some_file.coffee'
22
+ #
23
+ # This will look for the following files:
24
+ #
25
+ # assets/css/some_file.scss
26
+ # assets/js/some_file.coffee
27
+ #
28
+ # If you want to change the paths where asset files are stored, see the
29
+ # Options section below.
30
+ #
31
+ # === Serving
32
+ #
33
+ # In your routes, call the r.assets method to add a route to your assets,
34
+ # which will make your app serve the rendered assets:
35
+ #
36
+ # route do |r|
37
+ # r.assets
38
+ # end
39
+ #
40
+ # You should generally call +r.assets+ inside the route block itself, and not
41
+ # under any branches of the routing tree.
42
+ #
43
+ # === Views
44
+ #
45
+ # In your layout view, use the assets method to add links to your CSS and
46
+ # javascript assets:
47
+ #
48
+ # <%= assets(:css) %>
49
+ # <%= assets(:js) %>
50
+ #
51
+ # You can add attributes to the tags by using an options hash:
52
+ #
53
+ # <%= assets(:css, :media => 'print') %>
54
+ #
55
+ # == Asset Groups
56
+ #
57
+ # The asset plugin supports groups for the cases where you have different
58
+ # css/js files for your front end and back end. To use asset groups, you
59
+ # pass a hash for the :css and/or :js options:
60
+ #
61
+ # plugin :assets, :css => {:frontend => 'some_frontend_file.scss',
62
+ # :backend => 'some_backend_file.scss'}
63
+ #
64
+ # This expects the following directory structure for your assets:
65
+ #
66
+ # assets/css/frontend/some_frontend_file.scss
67
+ # assets/css/backend/some_backend_file.scss
68
+ #
69
+ # If you want do not want to force that directory structure when using
70
+ # asset groups, you can use the <tt>:group_subdirs => false</tt> option.
71
+ #
72
+ # In your view code use an array argument in your call to assets:
73
+ #
74
+ # <%= assets([:css, :frontend]) %>
75
+ #
76
+ # === Nesting
77
+ #
78
+ # Asset groups also supporting nesting, though that should only be needed
79
+ # in fairly large applications. You can use a nested hash when loading
80
+ # the plugin:
81
+ #
82
+ # plugin :assets,
83
+ # :css => {:frontend => {:dashboard => 'some_frontend_file.scss'}}
84
+ #
85
+ # and an extra entry per nesting level when creating the tags.
86
+ #
87
+ # <%= assets([:css, :frontend, :dashboard]) %>
88
+ #
89
+ # == Caching
90
+ #
91
+ # The assets plugin uses the caching plugin internally, and will set the
92
+ # Last-Modified header to the modified timestamp of the asset source file
93
+ # when rendering the asset.
94
+ #
95
+ # If you have assets that include other asset files, such as using @import
96
+ # in a sass file, you need to specify the dependencies for your assets so
97
+ # that the assets plugin will correctly pick up changes. You can do this
98
+ # using the :dependencies option to the plugin, which takes a hash where
99
+ # the keys are paths to asset files, and values are arrays of paths to
100
+ # dependencies of those asset files:
101
+ #
102
+ # app.plugin :assets,
103
+ # :dependencies=>{'assets/css/bootstrap.scss'=>Dir['assets/css/bootstrap/' '**/*.scss']}
104
+ #
105
+ # == Asset Compilation
106
+ #
107
+ # In production, you are generally going to want to compile your assets
108
+ # into a single file, with you can do by calling compile_assets after
109
+ # loading the plugin:
110
+ #
111
+ # plugin :assets, :css => 'some_file.scss', :js => 'some_file.coffee'
112
+ # compile_assets
113
+ #
114
+ # After calling compile_assets, calls to assets in your views will default
115
+ # to a using a single link each to your CSS and javascript compiled asset
116
+ # files. By default the compiled files are written to the public directory,
117
+ # so that they can be served by the webserver.
118
+ #
119
+ # === Asset Compression
120
+ #
121
+ # If you have the yuicompressor gem installed and working, it will be used
122
+ # automatically to compress your javascript and css assets. Otherwise,
123
+ # the assets will just be concatenated together and not compressed during
124
+ # compilation.
125
+ #
126
+ # === With Asset Groups
127
+ #
128
+ # When using asset groups, a separate compiled file will be produced per
129
+ # asset group.
130
+ #
131
+ # === Unique Asset Names
132
+ #
133
+ # When compiling assets, a unique name is given to each asset file, using the
134
+ # a SHA1 hash of the content of the file. This is done so that clients do
135
+ # not attempt to use cached versions of the assets if the asset has changed.
136
+ #
137
+ # === Serving
138
+ #
139
+ # If you call +r.assets+ when compiling assets, will serve the compiled asset
140
+ # files. However, it is recommended to have the main webserver (e.g. nginx)
141
+ # serve the compiled files, instead of relying on the application.
142
+ #
143
+ # Assuming you are using compiled assets in production mode that are served
144
+ # by the webserver, you can remove the serving of them by the application:
145
+ #
146
+ # route do |r|
147
+ # r.assets unless ENV['RACK_ENV'] == 'production'
148
+ # end
149
+ #
150
+ # If you do have the application serve the compiled assets, it will use the
151
+ # Last-Modified header to make sure that clients do not redownload compiled
152
+ # assets that haven't changed.
153
+ #
154
+ # === Asset Precompilation
155
+ #
156
+ # If you want to precompile your assets, so they do not need to be compiled
157
+ # every time you boot the application, you can provide a :precompiled option
158
+ # when loading the plugin. The value of this option should be the filename
159
+ # where the compiled asset metadata is stored.
160
+ #
161
+ # If the compiled assset metadata file does not exist when the assets plugin
162
+ # is loaded, the plugin will run in non-compiled mode. However, when you call
163
+ # compile_assets, it will write the compiled asset metadata file after
164
+ # compiling the assets.
165
+ #
166
+ # If the compiled asset metadata file already exists when the assets plugin
167
+ # is loaded, the plugin will read the file to get the compiled asset metadata,
168
+ # and it will run in compiled mode, assuming that the compiled asset files
169
+ # already exist.
170
+ #
171
+ # ==== On Heroku
172
+ #
173
+ # Heroku supports precompiling the assets when using Roda. You just need to
174
+ # add an assets:precompile task, similar to this:
175
+ #
176
+ # namespace :assets do
177
+ # desc "Precompile the assets"
178
+ # task :precompile do
179
+ # require './app'
180
+ # App.compile_assets
181
+ # end
182
+ # end
183
+ #
184
+ # == Plugin Options
185
+ #
186
+ # :add_suffix :: Whether to append a .css or .js extension to asset routes in non-compiled mode
187
+ # (default: false)
188
+ # :compiled_css_dir :: Directory name in which to store the compiled css file,
189
+ # inside :compiled_path (default: nil)
190
+ # :compiled_css_route :: Route under :prefix for compiled css assets (default: :compiled_css_dir)
191
+ # :compiled_js_dir :: Directory name in which to store the compiled javascript file,
192
+ # inside :compiled_path (default: nil)
193
+ # :compiled_js_route :: Route under :prefix for compiled javscript assets (default: :compiled_js_dir)
194
+ # :compiled_name :: Compiled file name prefix (default: 'app')
195
+ # :compiled_path:: Path inside public folder in which compiled files are stored (default: :prefix)
196
+ # :concat_only :: Whether to just concatenate instead of concatentating
197
+ # and compressing files (default: false)
198
+ # :css_dir :: Directory name containing your css source, inside :path (default: 'css')
199
+ # :css_headers :: A hash of additional headers for your rendered css files
200
+ # :css_opts :: Options to pass to the render plugin when rendering css assets
201
+ # :css_route :: Route under :prefix for css assets (default: :css_dir)
202
+ # :dependencies :: A hash of dependencies for your asset files. Keys should be paths to asset files,
203
+ # values should be arrays of paths your asset files depends on. This is used to
204
+ # detect changes in your asset files.
205
+ # :group_subdirs :: Whether a hash used in :css and :js options requires the assets for the
206
+ # related group are contained in a subdirectory with the same name (default: true)
207
+ # :headers :: A hash of additional headers for both js and css rendered files
208
+ # :js_dir :: Directory name containing your javascript source, inside :path (default: 'js')
209
+ # :js_headers :: A hash of additional headers for your rendered javascript files
210
+ # :js_opts :: Options to pass to the render plugin when rendering javascript assets
211
+ # :js_route :: Route under :prefix for javascript assets (default: :js_dir)
212
+ # :path :: Path to your asset source directory (default: 'assets')
213
+ # :prefix :: Prefix for assets path in your URL/routes (default: 'assets')
214
+ # :precompiled :: Path to the compiled asset metadata file. If the file exists, will use compiled
215
+ # mode using the metadata in the file. If the file does not exist, will use
216
+ # non-compiled mode, but will write the metadata to the file if compile_assets is called.
217
+ # :public :: Path to your public folder, in which compiled files are placed (default: 'public')
218
+ module Assets
219
+ DEFAULTS = {
220
+ :compiled_name => 'app'.freeze,
221
+ :js_dir => 'js'.freeze,
222
+ :css_dir => 'css'.freeze,
223
+ :path => 'assets'.freeze,
224
+ :prefix => 'assets'.freeze,
225
+ :public => 'public'.freeze,
226
+ :concat_only => false,
227
+ :compiled => false,
228
+ :add_suffix => false,
229
+ :group_subdirs => true,
230
+ :compiled_css_dir => nil,
231
+ :compiled_js_dir => nil,
232
+ }.freeze
233
+ JS_END = "\"></script>".freeze
234
+ CSS_END = "\" />".freeze
235
+ SPACE = ' '.freeze
236
+ DOT = '.'.freeze
237
+ SLASH = '/'.freeze
238
+ NEWLINE = "\n".freeze
239
+ EMPTY_STRING = ''.freeze
240
+ JS_SUFFIX = '.js'.freeze
241
+ CSS_SUFFIX = '.css'.freeze
242
+
243
+ # Load the render and caching plugins plugins, since the assets plugin
244
+ # depends on them.
245
+ def self.load_dependencies(app, _opts = {})
246
+ app.plugin :render
247
+ app.plugin :caching
248
+ end
249
+
250
+ # Setup the options for the plugin. See the Assets module RDoc
251
+ # for a description of the supported options.
252
+ def self.configure(app, opts = {})
253
+ if app.assets_opts
254
+ prev_opts = app.assets_opts[:orig_opts]
255
+ orig_opts = app.assets_opts[:orig_opts].merge(opts)
256
+ [:headers, :css_headers, :js_headers, :css_opts, :js_opts, :dependencies].each do |s|
257
+ if prev_opts[s]
258
+ if opts[s]
259
+ orig_opts[s] = prev_opts[s].merge(opts[s])
260
+ else
261
+ orig_opts[s] = prev_opts[s].dup
262
+ end
263
+ end
264
+ end
265
+ app.opts[:assets] = orig_opts.dup
266
+ app.opts[:assets][:orig_opts] = orig_opts
267
+ else
268
+ app.opts[:assets] = opts.dup
269
+ app.opts[:assets][:orig_opts] = opts
270
+ end
271
+ opts = app.opts[:assets]
272
+
273
+ # Combine multiple values into a path, ignoring trailing slashes
274
+ j = lambda do |*v|
275
+ opts.values_at(*v).
276
+ reject{|s| s.to_s.empty?}.
277
+ map{|s| s.chomp('/')}.
278
+ join('/').freeze
279
+ end
280
+
281
+ # Same as j, but add a trailing slash if not empty
282
+ sj = lambda do |*v|
283
+ s = j.call(*v)
284
+ s.empty? ? s : (s + '/').freeze
285
+ end
286
+
287
+ if opts[:precompiled] && !opts[:compiled] && ::File.exist?(opts[:precompiled])
288
+ require 'json'
289
+ opts[:compiled] = ::JSON.parse(::File.read(opts[:precompiled]))
290
+ end
291
+
292
+ DEFAULTS.each do |k, v|
293
+ opts[k] = v unless opts.has_key?(k)
294
+ end
295
+
296
+ [
297
+ [:compiled_path, :prefix],
298
+ [:js_route, :js_dir],
299
+ [:css_route, :css_dir],
300
+ [:compiled_js_route, :compiled_js_dir],
301
+ [:compiled_css_route, :compiled_css_dir]
302
+ ].each do |k, v|
303
+ opts[k] = opts[v] unless opts.has_key?(k)
304
+ end
305
+
306
+ [:css_headers, :js_headers, :css_opts, :js_opts, :dependencies].each do |s|
307
+ opts[s] ||= {}
308
+ end
309
+
310
+ if headers = opts[:headers]
311
+ opts[:css_headers] = headers.merge(opts[:css_headers])
312
+ opts[:js_headers] = headers.merge(opts[:js_headers])
313
+ end
314
+ opts[:css_headers]['Content-Type'] ||= "text/css; charset=UTF-8".freeze
315
+ opts[:js_headers]['Content-Type'] ||= "application/javascript; charset=UTF-8".freeze
316
+
317
+ [:css_headers, :js_headers, :css_opts, :js_opts, :dependencies].each do |s|
318
+ opts[s].freeze
319
+ end
320
+ [:headers, :css, :js].each do |s|
321
+ opts[s].freeze if opts[s]
322
+ end
323
+
324
+ # Used for reading/writing files
325
+ opts[:js_path] = sj.call(:path, :js_dir)
326
+ opts[:css_path] = sj.call(:path, :css_dir)
327
+ opts[:compiled_js_path] = j.call(:public, :compiled_path, :compiled_js_dir, :compiled_name)
328
+ opts[:compiled_css_path] = j.call(:public, :compiled_path, :compiled_css_dir, :compiled_name)
329
+
330
+ # Used for URLs/routes
331
+ opts[:js_prefix] = sj.call(:prefix, :js_route)
332
+ opts[:css_prefix] = sj.call(:prefix, :css_route)
333
+ opts[:compiled_js_prefix] = j.call(:prefix, :compiled_js_route, :compiled_name)
334
+ opts[:compiled_css_prefix] = j.call(:prefix, :compiled_css_route, :compiled_name)
335
+ opts[:js_suffix] = opts[:add_suffix] ? JS_SUFFIX : EMPTY_STRING
336
+ opts[:css_suffix] = opts[:add_suffix] ? CSS_SUFFIX : EMPTY_STRING
337
+
338
+ opts.freeze
339
+ end
340
+
341
+ module ClassMethods
342
+ # Return the assets options for this class.
343
+ def assets_opts
344
+ opts[:assets]
345
+ end
346
+
347
+ # Compile options for the given asset type. If no asset_type
348
+ # is given, compile both the :css and :js asset types. You
349
+ # can specify an array of types (e.g. [:css, :frontend]) to
350
+ # compile assets for the given asset group.
351
+ def compile_assets(type=nil)
352
+ require 'fileutils'
353
+
354
+ unless assets_opts[:compiled]
355
+ opts[:assets] = assets_opts.merge(:compiled => {})
356
+ end
357
+
358
+ if type == nil
359
+ _compile_assets(:css)
360
+ _compile_assets(:js)
361
+ else
362
+ _compile_assets(type)
363
+ end
364
+
365
+ if assets_opts[:precompiled]
366
+ require 'json'
367
+ ::FileUtils.mkdir_p(File.dirname(assets_opts[:precompiled]))
368
+ ::File.open(assets_opts[:precompiled], 'wb'){|f| f.write(assets_opts[:compiled].to_json)}
369
+ end
370
+
371
+ assets_opts[:compiled]
372
+ end
373
+
374
+ private
375
+
376
+ # Internals of compile_assets, handling recursive calls for loading
377
+ # all asset groups under the given type.
378
+ def _compile_assets(type)
379
+ type, *dirs = type if type.is_a?(Array)
380
+ dirs ||= []
381
+ files = assets_opts[type]
382
+ dirs.each{|d| files = files[d]}
383
+
384
+ case files
385
+ when Hash
386
+ files.each_key{|dir| _compile_assets([type] + dirs + [dir])}
387
+ else
388
+ files = Array(files)
389
+ compile_assets_files(files, type, dirs) unless files.empty?
390
+ end
391
+ end
392
+
393
+ # Compile each array of files for the given type into a single
394
+ # file. Dirs should be an array of asset group names, if these
395
+ # are files in an asset group.
396
+ def compile_assets_files(files, type, dirs)
397
+ dirs = nil if dirs && dirs.empty?
398
+ o = assets_opts
399
+ app = new
400
+
401
+ content = files.map do |file|
402
+ file = "#{dirs.join('/')}/#{file}" if dirs && o[:group_subdirs]
403
+ file = "#{o[:"#{type}_path"]}#{file}"
404
+ app.read_asset_file(file, type)
405
+ end.join
406
+
407
+ unless o[:concat_only]
408
+ content = compress_asset(content, type)
409
+ end
410
+
411
+ suffix = ".#{dirs.join('.')}" if dirs
412
+ key = "#{type}#{suffix}"
413
+ unique_id = o[:compiled][key] = asset_digest(content)
414
+ path = "#{o[:"compiled_#{type}_path"]}#{suffix}.#{unique_id}.#{type}"
415
+ ::FileUtils.mkdir_p(File.dirname(path))
416
+ ::File.open(path, 'wb'){|f| f.write(content)}
417
+ nil
418
+ end
419
+
420
+ # Compress the given content for the given type using yuicompressor,
421
+ # but handle cases where yuicompressor isn't installed or can't find
422
+ # a java runtime. This method can be overridden by the application
423
+ # to use a different compressor.
424
+ def compress_asset(content, type)
425
+ require 'yuicompressor'
426
+ # :nocov:
427
+ content = YUICompressor.send("compress_#{type}", content, :munge => true)
428
+ # :nocov:
429
+ rescue LoadError, Errno::ENOENT
430
+ # yuicompressor or java not available, just use concatenated, uncompressed output
431
+ content
432
+ end
433
+
434
+ # Return a unique id for the given content. By default, uses the
435
+ # SHA1 hash of the content. This method can be overridden to use
436
+ # a different digest type or to return a static string if you don't
437
+ # want to use a unique value.
438
+ def asset_digest(content)
439
+ require 'digest/sha1'
440
+ Digest::SHA1.hexdigest(content)
441
+ end
442
+ end
443
+
444
+ module InstanceMethods
445
+ # Return a string containing html tags for the given asset type.
446
+ # This will use a script tag for the :js type and a link tag for
447
+ # the :css type.
448
+ #
449
+ # To return the tags for a specific asset group, use an array for
450
+ # the type, such as [:css, :frontend].
451
+ #
452
+ # When the assets are not compiled, this will result in a separate
453
+ # tag for each asset file. When the assets are compiled, this will
454
+ # result in a single tag to the compiled asset file.
455
+ def assets(type, attrs = nil)
456
+ o = self.class.assets_opts
457
+ type, *dirs = type if type.is_a?(Array)
458
+ stype = type.to_s
459
+
460
+ attrs = if attrs
461
+ ru = Rack::Utils
462
+ attrs.map{|k,v| "#{k}=\"#{ru.escape_html(v.to_s)}\""}.join(SPACE)
463
+ else
464
+ EMPTY_STRING
465
+ end
466
+
467
+ if type == :js
468
+ tag_start = "<script type=\"text/javascript\" #{attrs} src=\"/"
469
+ tag_end = JS_END
470
+ else
471
+ tag_start = "<link rel=\"stylesheet\" #{attrs} href=\"/"
472
+ tag_end = CSS_END
473
+ end
474
+
475
+ # Create a tag for each individual file
476
+ if compiled = o[:compiled]
477
+ if dirs && !dirs.empty?
478
+ key = dirs.join(DOT)
479
+ ckey = "#{stype}.#{key}"
480
+ if ukey = compiled[ckey]
481
+ "#{tag_start}#{o[:"compiled_#{stype}_prefix"]}.#{key}.#{ukey}.#{stype}#{tag_end}"
482
+ end
483
+ elsif ukey = compiled[stype]
484
+ "#{tag_start}#{o[:"compiled_#{stype}_prefix"]}.#{ukey}.#{stype}#{tag_end}"
485
+ end
486
+ else
487
+ asset_dir = o[type]
488
+ if dirs && !dirs.empty?
489
+ dirs.each{|f| asset_dir = asset_dir[f]}
490
+ prefix = "#{dirs.join(SLASH)}/" if o[:group_subdirs]
491
+ end
492
+ Array(asset_dir).map{|f| "#{tag_start}#{o[:"#{stype}_prefix"]}#{prefix}#{f}#{o[:"#{stype}_suffix"]}#{tag_end}"}.join(NEWLINE)
493
+ end
494
+ end
495
+
496
+ # Render the asset with the given filename. When assets are compiled,
497
+ # or when the file is already of the given type (no rendering necessary),
498
+ # this returns the contents of the compiled file.
499
+ # When assets are not compiled and the file is not already of the correct,
500
+ # this will render the asset using the render plugin.
501
+ # In both cases, if the file has not been modified since the last request,
502
+ # this will return a 304 response.
503
+ def render_asset(file, type)
504
+ o = self.class.assets_opts
505
+ if o[:compiled]
506
+ file = "#{o[:"compiled_#{type}_path"]}#{file}"
507
+ check_asset_request(file, type, ::File.stat(file).mtime)
508
+ ::File.read(file)
509
+ else
510
+ file = "#{o[:"#{type}_path"]}#{file}"
511
+ check_asset_request(file, type, asset_last_modified(file))
512
+ read_asset_file(file, type)
513
+ end
514
+ end
515
+
516
+ # Return the content of the file if it is already of the correct type.
517
+ # Otherwise, render the file using the render plugin. +file+ should be
518
+ # the relative path to the file from the current directory.
519
+ def read_asset_file(file, type)
520
+ if file.end_with?(".#{type}")
521
+ ::File.read(file)
522
+ else
523
+ render_asset_file(file, self.class.assets_opts[:"#{type}_opts"])
524
+ end
525
+ end
526
+
527
+ private
528
+
529
+ # Return when the file was last modified. If the file depends on any
530
+ # other files, check the modification times of all dependencies and
531
+ # return the maximum.
532
+ def asset_last_modified(file)
533
+ if deps = self.class.assets_opts[:dependencies][file]
534
+ ([file] + Array(deps)).map{|f| ::File.stat(f).mtime}.max
535
+ else
536
+ ::File.stat(file).mtime
537
+ end
538
+ end
539
+
540
+ # If the asset hasn't been modified since the last request, return
541
+ # a 304 response immediately. Otherwise, add the appropriate
542
+ # type-specific headers.
543
+ def check_asset_request(file, type, mtime)
544
+ request.last_modified(mtime)
545
+ response.headers.merge!(self.class.assets_opts[:"#{type}_headers"])
546
+ end
547
+
548
+ # Render the given asset file using the render plugin, with the given options.
549
+ # +file+ should be the relative path to the file from the current directory.
550
+ def render_asset_file(file, options)
551
+ render({:path => file}, options)
552
+ end
553
+ end
554
+
555
+ module RequestClassMethods
556
+ # An array of asset type strings and regexps for that type, for all asset types
557
+ # handled.
558
+ def assets_matchers
559
+ @assets_matchers ||= [:css, :js].map do |t|
560
+ [t.to_s.freeze, assets_regexp(t)].freeze if roda_class.assets_opts[t]
561
+ end.compact.freeze
562
+ end
563
+
564
+ private
565
+
566
+ # The regexp matcher to use for the given type. This handles any asset groups
567
+ # for the asset types.
568
+ def assets_regexp(type)
569
+ o = roda_class.assets_opts
570
+ if compiled = o[:compiled]
571
+ assets = compiled.select{|k,_| k =~ /\A#{type}/}.map do |k, md|
572
+ "#{k.sub(/\A#{type}/, '')}.#{md}.#{type}"
573
+ end
574
+ /#{o[:"compiled_#{type}_prefix"]}(#{Regexp.union(assets)})/
575
+ else
576
+ assets = unnest_assets_hash(o[type])
577
+ /#{o[:"#{type}_prefix"]}(#{Regexp.union(assets.uniq)})#{o[:"#{type}_suffix"]}/
578
+ end
579
+ end
580
+
581
+ # Recursively unnested the given assets hash, returning a single array of asset
582
+ # files for the given.
583
+ def unnest_assets_hash(h)
584
+ case h
585
+ when Hash
586
+ h.map do |k,v|
587
+ assets = unnest_assets_hash(v)
588
+ assets = assets.map{|x| "#{k}/#{x}"} if roda_class.assets_opts[:group_subdirs]
589
+ assets
590
+ end.flatten(1)
591
+ else
592
+ Array(h)
593
+ end
594
+ end
595
+ end
596
+
597
+ module RequestMethods
598
+ # Render the matching asset if this is a GET request for a supported asset.
599
+ def assets
600
+ if is_get?
601
+ self.class.assets_matchers.each do |type, matcher|
602
+ is matcher do |file|
603
+ scope.render_asset(file, type)
604
+ end
605
+ end
606
+ end
607
+ end
608
+ end
609
+ end
610
+
611
+ register_plugin(:assets, Assets)
612
+ end
613
+ end