roda 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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