roda-cj 0.9.6 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +16 -0
- data/README.rdoc +211 -103
- data/Rakefile +1 -1
- data/doc/release_notes/1.0.0.txt +329 -0
- data/lib/roda.rb +295 -42
- data/lib/roda/plugins/all_verbs.rb +1 -1
- data/lib/roda/plugins/assets.rb +277 -0
- data/lib/roda/plugins/backtracking_array.rb +1 -1
- data/lib/roda/plugins/error_email.rb +110 -0
- data/lib/roda/plugins/multi_route.rb +14 -3
- data/lib/roda/plugins/not_allowed.rb +10 -3
- data/lib/roda/plugins/path.rb +38 -0
- data/lib/roda/plugins/symbol_matchers.rb +1 -1
- data/lib/roda/plugins/view_subdirs.rb +7 -1
- data/lib/roda/version.rb +1 -1
- data/spec/integration_spec.rb +95 -3
- data/spec/plugin/_erubis_escaping_spec.rb +1 -0
- data/spec/plugin/assets_spec.rb +86 -0
- data/spec/plugin/error_email_spec.rb +68 -0
- data/spec/plugin/multi_route_spec.rb +22 -0
- data/spec/plugin/not_allowed_spec.rb +13 -0
- data/spec/plugin/path_spec.rb +29 -0
- metadata +104 -66
- checksums.yaml +0 -7
@@ -0,0 +1,277 @@
|
|
1
|
+
require "tilt"
|
2
|
+
require 'open-uri'
|
3
|
+
|
4
|
+
class Roda
|
5
|
+
module RodaPlugins
|
6
|
+
module Assets
|
7
|
+
def self.load_dependencies(app, opts={})
|
8
|
+
app.plugin :render
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.configure(app, opts={}, &block)
|
12
|
+
if app.opts[:assets]
|
13
|
+
app.opts[:assets].merge!(opts)
|
14
|
+
else
|
15
|
+
app.opts[:assets] = opts.dup
|
16
|
+
end
|
17
|
+
|
18
|
+
opts = app.opts[:assets]
|
19
|
+
opts[:css] ||= []
|
20
|
+
opts[:js] ||= []
|
21
|
+
opts[:js_folder] ||= 'js'
|
22
|
+
opts[:css_folder] ||= 'css'
|
23
|
+
opts[:path] ||= File.expand_path("assets", Dir.pwd)
|
24
|
+
opts[:compiled_path] ||= opts[:path]
|
25
|
+
opts[:compiled_name] ||= 'compiled.roda.assets'
|
26
|
+
opts[:concat_name] ||= 'concat.roda.assets'
|
27
|
+
opts[:route] ||= 'assets'
|
28
|
+
opts[:css_engine] ||= 'scss'
|
29
|
+
opts[:js_engine] ||= 'coffee'
|
30
|
+
opts[:concat] ||= false
|
31
|
+
opts[:compiled] ||= false
|
32
|
+
opts[:headers] ||= {}
|
33
|
+
|
34
|
+
if opts.fetch(:cache, true)
|
35
|
+
opts[:cache] = app.thread_safe_cache
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
module ClassMethods
|
40
|
+
# Copy the assets options into the subclass, duping
|
41
|
+
# them as necessary to prevent changes in the subclass
|
42
|
+
# affecting the parent class.
|
43
|
+
def inherited(subclass)
|
44
|
+
super
|
45
|
+
opts = subclass.opts[:assets] = assets_opts.dup
|
46
|
+
opts[:cache] = thread_safe_cache if opts[:cache]
|
47
|
+
end
|
48
|
+
|
49
|
+
# Return the assets options for this class.
|
50
|
+
def assets_opts
|
51
|
+
opts[:assets]
|
52
|
+
end
|
53
|
+
|
54
|
+
# Generates a unique id, this is used to keep concat/compiled files
|
55
|
+
# from caching in the browser when they are generated
|
56
|
+
def assets_unique_id type
|
57
|
+
if unique_id = instance_variable_get("@#{type}")
|
58
|
+
unique_id
|
59
|
+
else
|
60
|
+
path = "#{assets_opts[:compiled_path]}/#{assets_opts[:"#{type}_folder"]}"
|
61
|
+
file = "#{path}/#{assets_opts[:compiled_name]}.#{type}"
|
62
|
+
content = File.exist?(file) ? File.read(file) : ''
|
63
|
+
|
64
|
+
instance_variable_set("@#{type}", Digest::SHA1.hexdigest(content))
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def compile_assets(concat_only = false)
|
69
|
+
assets_opts[:concat_only] = concat_only
|
70
|
+
|
71
|
+
%w(css js).each do |type|
|
72
|
+
files = assets_opts[type.to_sym]
|
73
|
+
|
74
|
+
if files.is_a? Array
|
75
|
+
compile_process_files files, type, type
|
76
|
+
else
|
77
|
+
files.each do |folder, f|
|
78
|
+
compile_process_files f, type, folder
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def compile_process_files files, type, folder
|
87
|
+
require 'yuicompressor'
|
88
|
+
|
89
|
+
r = new
|
90
|
+
|
91
|
+
content = ''
|
92
|
+
|
93
|
+
files.each do |file|
|
94
|
+
if type != folder
|
95
|
+
file = "#{folder}/#{file}"
|
96
|
+
end
|
97
|
+
|
98
|
+
content += r.read_asset_file file, type
|
99
|
+
end
|
100
|
+
|
101
|
+
path = assets_opts[:compiled_path] \
|
102
|
+
+ "/#{assets_opts[:"#{type}_folder"]}/" \
|
103
|
+
+ assets_opts[:compiled_name] \
|
104
|
+
+ (type != folder ? ".#{folder}" : '') \
|
105
|
+
+ ".#{type}"
|
106
|
+
|
107
|
+
unless assets_opts[:concat_only]
|
108
|
+
content = YUICompressor.send("compress_#{type}", content, munge: true)
|
109
|
+
end
|
110
|
+
|
111
|
+
File.write path, content
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
module InstanceMethods
|
116
|
+
# This will ouput the files with the appropriate tags
|
117
|
+
def assets folder, options = {}
|
118
|
+
attrs = options.map{|k,v| "#{k}=\"#{v}\""}
|
119
|
+
tags = []
|
120
|
+
folder = [folder] unless folder.is_a? Array
|
121
|
+
type = folder.first
|
122
|
+
attr = type.to_s == 'js' ? 'src' : 'href'
|
123
|
+
path = "#{assets_opts[:route]}/#{assets_opts[:"#{type}_folder"]}"
|
124
|
+
files = folder.length == 1 \
|
125
|
+
? assets_opts[:"#{folder[0]}"] \
|
126
|
+
: assets_opts[:"#{folder[0]}"][:"#{folder[1]}"]
|
127
|
+
|
128
|
+
# Create a tag for each individual file
|
129
|
+
if !assets_opts[:concat]
|
130
|
+
files.each do |file|
|
131
|
+
# This allows you to do things like:
|
132
|
+
# assets_opts[:css] = ['app', './bower/jquery/jquery-min.js']
|
133
|
+
file.gsub!(/\./, '$2E')
|
134
|
+
# Add tags to the tags array
|
135
|
+
tags << send(
|
136
|
+
"#{type}_assets_tag",
|
137
|
+
attrs.dup.unshift( "#{attr}=\"/#{path}/#{file}.#{type}\"").join(' ')
|
138
|
+
)
|
139
|
+
end
|
140
|
+
# Return tags as string
|
141
|
+
tags.join "\n"
|
142
|
+
else
|
143
|
+
name = assets_opts[:compiled] ? assets_opts[:compiled_name] : assets_opts[:concat_name]
|
144
|
+
name = "#{name}/#{folder.join('-')}"
|
145
|
+
# Generate unique url so middleware knows to check for # compile/concat
|
146
|
+
attrs.unshift("#{attr}=\"/#{path}/#{name}/#{assets_unique_id(type)}.#{type}\"")
|
147
|
+
# Return tag string
|
148
|
+
send("#{type}_assets_tag", attrs.join(' '))
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def render_asset(file, type)
|
153
|
+
file.gsub!(/(\$2E|%242E)/, '.')
|
154
|
+
|
155
|
+
if !assets_opts[:compiled] && !assets_opts[:concat]
|
156
|
+
read_asset_file file, type
|
157
|
+
elsif assets_opts[:compiled]
|
158
|
+
folder = file.split('/')[1].split('-', 2)
|
159
|
+
path = assets_opts[:compiled_path] \
|
160
|
+
+ "/#{assets_opts[:"#{type}_folder"]}/" \
|
161
|
+
+ assets_opts[:compiled_name] \
|
162
|
+
+ (folder.length > 1 ? ".#{folder[1]}" : '') \
|
163
|
+
+ ".#{type}"
|
164
|
+
|
165
|
+
File.read path
|
166
|
+
elsif assets_opts[:concat]
|
167
|
+
# "concat.roda.assets/css/123"
|
168
|
+
content = ''
|
169
|
+
folder = file.split('/')[1].split('-', 2)
|
170
|
+
files = folder.length == 1 \
|
171
|
+
? assets_opts[:"#{folder[0]}"] \
|
172
|
+
: assets_opts[:"#{folder[0]}"][:"#{folder[1]}"]
|
173
|
+
|
174
|
+
files.each { |f| content += read_asset_file f, type }
|
175
|
+
|
176
|
+
content
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def read_asset_file file, type
|
181
|
+
folder = assets_opts[:"#{type}_folder"]
|
182
|
+
|
183
|
+
# If there is no file it must be a remote file request.
|
184
|
+
# Lets set the file to the url
|
185
|
+
if file == ''
|
186
|
+
route = assets_opts[:route]
|
187
|
+
file = env['SCRIPT_NAME'].gsub(/^\/#{route}\/#{folder}\//, '')
|
188
|
+
end
|
189
|
+
|
190
|
+
if !file[/^\.\//] && !file[/^http/]
|
191
|
+
path = assets_opts[:path] + '/' + folder + "/#{file}"
|
192
|
+
else
|
193
|
+
path = file
|
194
|
+
end
|
195
|
+
|
196
|
+
engine = assets_opts[:"#{type}_engine"]
|
197
|
+
|
198
|
+
# render via tilt
|
199
|
+
if File.exists? "#{path}.#{engine}"
|
200
|
+
render path: "#{path}.#{engine}"
|
201
|
+
# read file directly
|
202
|
+
elsif File.exists? "#{path}.#{type}"
|
203
|
+
File.read "#{path}.#{type}"
|
204
|
+
# grab remote file content
|
205
|
+
elsif file[/^http/]
|
206
|
+
open(file).read
|
207
|
+
# as a last attempt lets see if the file can be rendered by tilt
|
208
|
+
# otherwise load the file directly
|
209
|
+
elsif File.exists? path
|
210
|
+
begin
|
211
|
+
render path: path
|
212
|
+
rescue
|
213
|
+
File.read path
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
# Shortcut for class opts
|
219
|
+
def assets_opts
|
220
|
+
self.class.assets_opts
|
221
|
+
end
|
222
|
+
|
223
|
+
# Shortcut for class assets unique id
|
224
|
+
def assets_unique_id(*args)
|
225
|
+
self.class.assets_unique_id(*args)
|
226
|
+
end
|
227
|
+
|
228
|
+
private
|
229
|
+
|
230
|
+
# CSS tag template
|
231
|
+
# <link rel="stylesheet" href="theme.css">
|
232
|
+
def css_assets_tag attrs
|
233
|
+
"<link rel=\"stylesheet\" #{attrs} />"
|
234
|
+
end
|
235
|
+
|
236
|
+
# JS tag template
|
237
|
+
# <script src="scriptfile.js"></script>
|
238
|
+
def js_assets_tag attrs
|
239
|
+
"<script type=\"text/javascript\" #{attrs}></script>"
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
module RequestClassMethods
|
244
|
+
# Shortcut for roda class asset opts
|
245
|
+
def assets_opts
|
246
|
+
roda_class.assets_opts
|
247
|
+
end
|
248
|
+
|
249
|
+
# The regex for the assets route
|
250
|
+
def assets_route_regex
|
251
|
+
Regexp.new(
|
252
|
+
assets_opts[:route] + '/' +
|
253
|
+
"(#{assets_opts[:"css_folder"]}|#{assets_opts[:"js_folder"]})" +
|
254
|
+
'/(.*)(?:\.(css|js)|http.*)$'
|
255
|
+
)
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
module RequestMethods
|
260
|
+
# Handles calls to the assets route
|
261
|
+
def assets
|
262
|
+
on self.class.assets_route_regex do |type, file|
|
263
|
+
content_type = type == 'css' ? 'text/css' : 'application/javascript'
|
264
|
+
|
265
|
+
response.headers.merge!({
|
266
|
+
"Content-Type" => content_type + '; charset=UTF-8',
|
267
|
+
}.merge(scope.assets_opts[:headers]))
|
268
|
+
|
269
|
+
scope.render_asset file, type
|
270
|
+
end
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
register_plugin(:assets, Assets)
|
276
|
+
end
|
277
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'net/smtp'
|
2
|
+
|
3
|
+
class Roda
|
4
|
+
module RodaPlugins
|
5
|
+
# The error_email plugin adds an +error_email+ instance method that
|
6
|
+
# send an email related to the exception. This is most useful if you are
|
7
|
+
# also using the error_handler plugin:
|
8
|
+
#
|
9
|
+
# plugin :error_email, :to=>'to@example.com', :from=>'from@example.com'
|
10
|
+
# plugin :error_handler do |e|
|
11
|
+
# error_email(e)
|
12
|
+
# 'Internal Server Error'
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# Options:
|
16
|
+
#
|
17
|
+
# :from :: The From address to use in the email (required)
|
18
|
+
# :headers :: A hash of additional headers to use in the email (default: empty hash)
|
19
|
+
# :host :: The SMTP server to use to send the email (default: localhost)
|
20
|
+
# :prefix :: A prefix to use in the email's subject line (default: no prefix)
|
21
|
+
# :to :: The To address to use in the email (required)
|
22
|
+
#
|
23
|
+
# The subject of the error email shows the exception class and message.
|
24
|
+
# The body of the error email shows the backtrace of the error and the
|
25
|
+
# request environment, as well the request params and session variables (if any).
|
26
|
+
#
|
27
|
+
# Note that emailing on every error as shown above is only appropriate
|
28
|
+
# for low traffic web applications. For high traffic web applications,
|
29
|
+
# use an error reporting service instead of this plugin.
|
30
|
+
module ErrorEmail
|
31
|
+
DEFAULTS = {
|
32
|
+
:headers=>{},
|
33
|
+
:host=>'localhost',
|
34
|
+
:emailer=>lambda{|h| Net::SMTP.start(h[:host]){|s| s.send_message(h[:message], h[:from], h[:to])}},
|
35
|
+
:default_headers=>lambda do |h, e|
|
36
|
+
{'From'=>h[:from], 'To'=>h[:to], 'Subject'=>"#{h[:prefix]}#{e.class}: #{e.message}"}
|
37
|
+
end,
|
38
|
+
:body=>lambda do |s, e|
|
39
|
+
format = lambda{|h| h.map{|k, v| "#{k.inspect} => #{v.inspect}"}.sort.join("\n")}
|
40
|
+
|
41
|
+
message = <<END
|
42
|
+
Path: #{s.request.full_path_info}
|
43
|
+
|
44
|
+
Backtrace:
|
45
|
+
|
46
|
+
#{e.backtrace.join("\n")}
|
47
|
+
|
48
|
+
ENV:
|
49
|
+
|
50
|
+
#{format[s.env]}
|
51
|
+
END
|
52
|
+
unless s.request.params.empty?
|
53
|
+
message << <<END
|
54
|
+
|
55
|
+
Params:
|
56
|
+
|
57
|
+
#{format[s.request.params]}
|
58
|
+
END
|
59
|
+
end
|
60
|
+
|
61
|
+
if s.env['rack.session']
|
62
|
+
message << <<END
|
63
|
+
|
64
|
+
Session:
|
65
|
+
|
66
|
+
#{format[s.session]}
|
67
|
+
END
|
68
|
+
end
|
69
|
+
|
70
|
+
message
|
71
|
+
end
|
72
|
+
}
|
73
|
+
|
74
|
+
# Set default opts for plugin. See ErrorEmail module RDoc for options.
|
75
|
+
def self.configure(app, opts={})
|
76
|
+
email_opts = app.opts[:error_email] ||= DEFAULTS
|
77
|
+
email_opts = email_opts.merge(opts)
|
78
|
+
unless email_opts[:to] && email_opts[:from]
|
79
|
+
raise RodaError, "must provide :to and :from options to error_email plugin"
|
80
|
+
end
|
81
|
+
app.opts[:error_email] = email_opts
|
82
|
+
end
|
83
|
+
|
84
|
+
module ClassMethods
|
85
|
+
# Dup the error email opts in the subclass so changes to the subclass do not affect
|
86
|
+
# the superclass.
|
87
|
+
def inherited(subclass)
|
88
|
+
super
|
89
|
+
subclass.opts[:error_email] = subclass.opts[:error_email].dup
|
90
|
+
subclass.opts[:error_email][:headers] = subclass.opts[:error_email][:headers].dup
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
module InstanceMethods
|
95
|
+
# Send an email for the given error.
|
96
|
+
def error_email(e)
|
97
|
+
email_opts = self.class.opts[:error_email].dup
|
98
|
+
headers = email_opts[:default_headers].call(email_opts, e)
|
99
|
+
headers = headers.merge(email_opts[:headers])
|
100
|
+
headers = headers.map{|k,v| "#{k}: #{v}"}.sort.join("\n")
|
101
|
+
body = email_opts[:body].call(self, e)
|
102
|
+
email_opts[:message] = "#{headers}\n\n#{body}"
|
103
|
+
email_opts[:emailer].call(email_opts)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
register_plugin(:error_email, ErrorEmail)
|
109
|
+
end
|
110
|
+
end
|
@@ -64,7 +64,7 @@ class Roda
|
|
64
64
|
subclass.instance_variable_set(:@named_routes, @named_routes.dup)
|
65
65
|
end
|
66
66
|
|
67
|
-
#
|
67
|
+
# The names for the currently stored named routes
|
68
68
|
def named_routes
|
69
69
|
@named_routes.keys
|
70
70
|
end
|
@@ -74,12 +74,13 @@ class Roda
|
|
74
74
|
@named_routes[name]
|
75
75
|
end
|
76
76
|
|
77
|
-
# If the given route has a
|
77
|
+
# If the given route has a name, treat it as a named route and
|
78
78
|
# store the route block. Otherwise, this is the main route, so
|
79
79
|
# call super.
|
80
80
|
def route(name=nil, &block)
|
81
81
|
if name
|
82
82
|
@named_routes[name] = block
|
83
|
+
self::RodaRequest.clear_named_route_regexp!
|
83
84
|
else
|
84
85
|
super(&block)
|
85
86
|
end
|
@@ -87,9 +88,19 @@ class Roda
|
|
87
88
|
end
|
88
89
|
|
89
90
|
module RequestClassMethods
|
91
|
+
# Clear cached regexp for named routes, it will be regenerated
|
92
|
+
# the next time it is needed.
|
93
|
+
#
|
94
|
+
# This shouldn't be an issue in production applications, but
|
95
|
+
# during development it's useful to support new named routes
|
96
|
+
# being added while the application is running.
|
97
|
+
def clear_named_route_regexp!
|
98
|
+
@named_route_regexp = nil
|
99
|
+
end
|
100
|
+
|
90
101
|
# A regexp matching any of the current named routes.
|
91
102
|
def named_route_regexp
|
92
|
-
@named_route_regexp ||= /(#{Regexp.union(roda_class.named_routes)})/
|
103
|
+
@named_route_regexp ||= /(#{Regexp.union(roda_class.named_routes.select{|s| s.is_a?(String)})})/
|
93
104
|
end
|
94
105
|
end
|
95
106
|
|