repertoire-assets 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,589 @@
1
+ require 'logger'
2
+
3
+ module Repertoire
4
+ module Assets
5
+ class Processor
6
+
7
+ attr_accessor :manifest, :provided
8
+
9
+ DEFAULT_OPTIONS = {
10
+ :precache => nil,
11
+ :compress => nil,
12
+ :disable_rack_assets => nil,
13
+
14
+ :path_prefix => '', # prefix to add before all urls
15
+ :js_source_files => # app javascript files to jumpstart dependency processing
16
+ [ 'public/javascripts/application.js', 'public/javascripts/*.js' ],
17
+
18
+ :gem_asset_roots => [ '../public' ], # location under $LOAD_PATHs to use as root for asset uris
19
+ :gem_libraries => # location under $LOAD_PATHs to search for javascript libraries
20
+ [ '../public/javascripts/*.js' ],
21
+
22
+ :cache_root => 'public', # app directory to put cache files & digests in, should be webserver visible
23
+ :digest_basename => 'digest', # file basename for css & js digests
24
+ :gem_excludes => [ ] # patterns under $LOAD_PATHs to exclude from the manifest
25
+ }
26
+
27
+
28
+ # Initialize the asset dependency system, configure the rack middleware,
29
+ # and precache assets (if requested).
30
+ #
31
+ # === Parameters
32
+ # :delegate::
33
+ # The rack app to serve assets for
34
+ # :settings::
35
+ # Hash of configuration options (below)
36
+ # :logger::
37
+ # Framework's logger - defaults to STDERR
38
+ #
39
+ # === Exceptions
40
+ # ConfigurationError
41
+ #
42
+ # Common settings and defaults
43
+ #
44
+ # :precache [false] # copy and bundle assets into host application?
45
+ # :compress [false] # compress bundled javascript & stylesheets? (implies :precache)
46
+ # :disable_rack_assets [false] # don't interpolate <script> and <link> tags (implies :precache)
47
+ # :path_prefix [''] # prefix for all generated urls
48
+ #
49
+ # For other very rarely used configuration options, see the source.
50
+ #
51
+ # ---
52
+ def initialize(delegate, settings={}, logger=nil)
53
+ @options = DEFAULT_OPTIONS.dup.merge(settings)
54
+ @logger = logger || Logger.new(STDERR)
55
+ Processor.verify_options(@options)
56
+
57
+ # configure rack middleware
58
+ @app = delegate
59
+ @manifester = Manifest.new(@app, self, @options, @logger)
60
+ @provider = Provides.new(@manifester, self, @options, @logger)
61
+
62
+ # build manifest from required javascripts
63
+ reset!
64
+
65
+ # if requested, cache assets in app's public directory at startup
66
+ @provider.precache! && @manifester.precache! if @options[:precache]
67
+ end
68
+
69
+
70
+ # The core rack call to process an http request. Calls the appropriate middleware
71
+ # to provide assets or interpolate the asset manifest.
72
+ #
73
+ # If asset precaching is turned off, the dependent files are checked to make
74
+ # sure the asset manifest is still valid before the request is processed.
75
+ #
76
+ # ---
77
+ def call(env)
78
+ delegate = case
79
+ when @options[:disable_rack_assets] then @app # ignore all asset middleware
80
+ when @options[:precache] then @manifester # use manifest middleware only
81
+ else
82
+ reset! if stale?
83
+ @provider # use provider + manifest middleware
84
+ end
85
+
86
+ delegate.call(env)
87
+ end
88
+
89
+
90
+ # Rebuild the manifest and provides lists from the javascript source files.
91
+ #
92
+ # ---
93
+ def reset!
94
+ @source_files = nil
95
+ @libraries = nil
96
+ @excludes = nil
97
+ @asset_roots = nil
98
+ @manifest = nil
99
+ @manifest_stamp = nil
100
+ @provided = nil
101
+
102
+ source_files.each do |path|
103
+ requires(path)
104
+ end
105
+
106
+ @manifest_timestamp = mtime
107
+
108
+ @logger.info "Assets processed: %i source files, %i libraries available, %i assets provided, %i required files in manifest" %
109
+ [ source_files.size, libraries.size, @provided.size, @manifest.size ]
110
+ end
111
+
112
+
113
+ # Compute the initial javascript source files to scan for dependencies.
114
+ # By default:
115
+ #
116
+ # <app_root>/public/javascripts/*.js
117
+ #
118
+ # If there is a file 'application.js', it will be processed before all
119
+ # others. In a complex application, divide your javascript into files
120
+ # in a directory below and 'require' them in application.js.
121
+ #
122
+ # ==== Returns
123
+ # :Array[Pathname]::
124
+ # The pathnames, in absolute format
125
+ #
126
+ # ---
127
+ def source_files
128
+ @source_files ||= Processor.expand_paths(['.'], @options[:js_source_files])
129
+ end
130
+
131
+
132
+ # Compute the load path for all potential assets that could be provided by
133
+ # a javascript library. By default:
134
+ #
135
+ # <gem_root>/public/
136
+ #
137
+ # For security, the middleware will not serve a file until it or an enclosing
138
+ # directory are explicitly required or provided by a javascript library file.
139
+ #
140
+ # ==== Returns
141
+ # Array[Pathname]:: The pathnames, in absolute format
142
+ #
143
+ # ---
144
+ def asset_roots
145
+ unless @asset_roots
146
+ @asset_roots = Processor.expand_paths($LOAD_PATH, @options[:gem_asset_roots])
147
+ @asset_roots << Processor.realpath(@options[:cache_root])
148
+ end
149
+
150
+ @asset_roots
151
+ end
152
+
153
+
154
+ # Compute the list of all available javascript libraries and their paths.
155
+ # By default, javascripts in the following paths will be found:
156
+ #
157
+ # <gem_root>/public/javascripts/*.js
158
+ #
159
+ # ==== Returns
160
+ # :Hash<String, Pathname>::
161
+ # The library names and their absolute paths.
162
+ #
163
+ # ---
164
+ def libraries
165
+ unless @libraries
166
+ @libraries = {}
167
+ paths = Processor.expand_paths($LOAD_PATH, @options[:gem_libraries])
168
+ paths.each do |path|
169
+ lib = Processor.library_name(path)
170
+ if @libraries[lib]
171
+ @logger.warn "Multiple libraries for <#{lib}>, using #{ Processor.pretty_path @libraries[lib] } (other is #{ Processor.pretty_path path })"
172
+ end
173
+ @libraries[lib] ||= path
174
+ end
175
+ end
176
+
177
+ @libraries
178
+ end
179
+
180
+
181
+ # Compute the basepaths for all excluded javascript libraries.
182
+ #
183
+ # ==== Returns
184
+ # :Array<String>::
185
+ # The list of base paths
186
+ #
187
+ # ---
188
+ def excludes
189
+ unless @excludes
190
+ @excludes = []
191
+ @options[:gem_excludes].map do |libname|
192
+ libpath = libraries[libname]
193
+ @excludes << libpath.dirname + libname if libpath
194
+ end
195
+ end
196
+
197
+ @excludes
198
+ end
199
+
200
+
201
+ # Determine if manifest or provided assets lists need to be regenerated
202
+ # because a required file has changed.
203
+ #
204
+ # ==== Parameters
205
+ # :product_times:: An optional list of times from product files that
206
+ # depend on the manifest.
207
+ #
208
+ # ==== Returns
209
+ # <boolean>
210
+ #
211
+ def stale?(*product_times)
212
+ product_times << @manifest_timestamp
213
+ source_time = mtime
214
+ current = @manifest &&
215
+ @provided &&
216
+ product_times.all? do |time|
217
+ time >= source_time
218
+ end
219
+ !current
220
+ end
221
+
222
+
223
+ # Calculate the most recent modification date among all javascript files
224
+ # available to build the manifest.
225
+ #
226
+ # ==== Returns
227
+ #
228
+ # <Time>:: The most recent time.
229
+ #
230
+ # ---
231
+ def mtime
232
+ paths = @source_files | @provided.values
233
+ mtimes = paths.map do |f|
234
+ File.exists?(f) ? File.mtime(f) : Time.now
235
+ end
236
+
237
+ mtimes.max
238
+ end
239
+
240
+
241
+ protected
242
+
243
+ # Add the required javascript file - and all that it requires in turn -
244
+ # to the manifest. Javascripts that have already been sourced are
245
+ # omitted.
246
+ #
247
+ # The javascript file will be provided for client http access at a uri
248
+ # relative to the gem asset root. e.g.
249
+ #
250
+ # <gem>/public/javascripts/my_module/circle.js
251
+ #
252
+ # will apppear at a comparable uri beneath the application root:
253
+ #
254
+ # http://javascripts/my_module/circle.js
255
+ #
256
+ # ==== Parameters
257
+ # :path::
258
+ # The path of the javascript file
259
+ # :indent::
260
+ # The logging indent level (for pretty-printing)
261
+ #
262
+ # ---
263
+ def requires(path, level=0)
264
+ @manifest ||= []
265
+ @provided ||= {}
266
+ uri = uri(path)
267
+
268
+ # only expand each source file once
269
+ return if @manifest.include?(uri)
270
+
271
+ # handle excluded libraries
272
+ if excludes.any? { |excluded| Processor.parent_path?(excluded, path) }
273
+ @logger.debug "Excluding #{' '*level + uri} (#{Processor.pretty_path(path)})"
274
+ return
275
+ end
276
+
277
+ @logger.debug "Requiring #{' '*level + uri} (#{Processor.pretty_path(path)})"
278
+
279
+ # preprocess directives in the file recursively
280
+ preprocess(path, level+1)
281
+
282
+ # add file after those it requires and register it as provided for http access
283
+ @manifest << uri
284
+ @provided[uri] = path
285
+ end
286
+
287
+
288
+ # Provide a given asset for client http access. If a directory is passed
289
+ # in, all files beneath it are provided.
290
+ #
291
+ # The assets will be provided for client http access at uris
292
+ # relative to the gem asset root. e.g.
293
+ #
294
+ # <gem>/public/images/my_module/circle.png
295
+ #
296
+ # will apppear at a comparable uri beneath the application root:
297
+ #
298
+ # http://images/my_module/circle.png
299
+ #
300
+ # ==== Parameters
301
+ # :path::
302
+ # The path of the asset or directory to provide
303
+ # :indent::
304
+ # The logging indent level (for pretty-printing)
305
+ #
306
+ # ---
307
+ def provides(path, level=0)
308
+ @provided ||= {}
309
+ uri = uri(path)
310
+
311
+ @logger.debug "Providing #{' '*level + uri} (#{Processor.pretty_path(path)})"
312
+
313
+ path.find do |sub|
314
+ @provided[ uri(sub) ] = sub if sub.file?
315
+ end
316
+ end
317
+
318
+
319
+ protected
320
+
321
+
322
+ # Recursively preprocess require and provide directives in a javascript file.
323
+ #
324
+ # ==== Parameters
325
+ # :path<Pathname>:: the path of the javascript file
326
+ # :level<Integer>:: the recursion level (for pretty-printing & errors)
327
+ #
328
+ # ==== Raises
329
+ # :UnknownAssetError::
330
+ # No file could be found for the given asset name
331
+ # :UnknownDirectiveError::
332
+ # An unknown processing directive was given
333
+ #
334
+ # ---
335
+ def preprocess(path, level=0)
336
+ line_num = 1
337
+ path.each_line do |line|
338
+ begin
339
+ # process any directives on line
340
+ directive(path.dirname, line, level)
341
+ rescue Error => e
342
+ @logger.error "Could not process '#{line.chomp}' (%s, line %i)" % [path, line_num]
343
+ error_status
344
+ raise e.message
345
+ end
346
+ line_num += 1
347
+ end
348
+ end
349
+
350
+ # Preprocess any directive on a single line. The file spec is progressively
351
+ # expanded, as follows.
352
+ #
353
+ # (1) <library> and <library/sublibrary> are dereferenced
354
+ # (2) "relative/file" is expanded based on the current working directory
355
+ # (3) globs are expanded (see Ruby Dir[])
356
+ # (4) the default extension (.js) is checked
357
+ # (5) finally, 'requires' or 'provides' are called on any resulting files
358
+ #
359
+ # ==== Parameters
360
+ # :cwd::
361
+ # The current working directory (for relative paths)
362
+ # :line::
363
+ # The line to process
364
+ # :level::
365
+ # The recursion level (for pretty-printing)
366
+ #
367
+ # ==== Raises
368
+ # :UnknownAssetError::
369
+ # The path does not refer to any existing file
370
+ # :UnknownDirectiveError::
371
+ # The line specified an unknown preprocessing directive
372
+ #
373
+ # ---
374
+ def directive(cwd, line, level=0)
375
+ # extract the preprocessing directive
376
+ return unless line[ %r{^\s*//=\s*(\w+)\s+(".+"|<.+>)\s*$} ]
377
+ directive, pathspec = $1, $2
378
+
379
+ # progressively expand path specification
380
+ pathlist = pathspec
381
+
382
+ # expand library and sublibrary references
383
+ pathlist.gsub!( %r{^<([^/>]*)/?(.*)>} ) do
384
+ libname, sublib = $1, $2
385
+ # determine library path
386
+ unless libpath = libraries[libname]
387
+ raise UnknownAssetError, libname
388
+ end
389
+ # distinguish library and sublibraries
390
+ if sublib.empty?
391
+ libpath.to_s
392
+ else
393
+ libpath.dirname + libname + sublib
394
+ end
395
+ end
396
+
397
+ # expand relative references
398
+ pathlist.gsub!( %r{"(.*)"} ) do
399
+ subpath = $1
400
+ cwd + subpath
401
+ end
402
+
403
+ # expand globs & default extension, match existing files
404
+ pathlist = Dir[ pathlist, pathlist + '.js' ]
405
+ pathlist.reject! { |p| File.directory?(p) }
406
+
407
+ # handle missing asset
408
+ raise UnknownAssetError, pathspec if pathlist.empty?
409
+
410
+ # perform directive over matches
411
+ pathlist.each do |p|
412
+ p = Pathname.new(p)
413
+ case directive
414
+ when 'require' then requires(p, level)
415
+ when 'provide' then provides(p, level)
416
+ else
417
+ raise UnknownDirectiveError, directive
418
+ end
419
+ end
420
+ end
421
+
422
+ # Locate the enclosing gem asset root and construct a relative uri to path
423
+ #
424
+ # ==== Parameters
425
+ # :path::
426
+ # The path of the asset
427
+ #
428
+ # ---
429
+ def uri(path)
430
+ return nil unless path
431
+ root = asset_roots.detect { |root| Processor.parent_path?(root, path) }
432
+ '/' + path.relative_path_from(root).to_s
433
+ end
434
+
435
+
436
+ # Check path references a valid set of files and give sensible error
437
+ # messages to locate the problem if not. If successful, each path is
438
+ # yielded in turn.
439
+ #
440
+ # ==== Parameters
441
+ # :path::
442
+ # The pathname to check
443
+ # :identifier::
444
+ # The reference the user supplied to identify the file
445
+ # :source_file::
446
+ # The filename the reference occurred in
447
+ # :line_num::
448
+ # The line number of the occurrence
449
+ # :&block::
450
+ # Block to run on all successful matches
451
+ #
452
+ # ==== Raises
453
+ # UnknownAssetError::
454
+ # The path does not refer to an existing file
455
+ #
456
+ # ---
457
+ def path_lint(path, identifier, source_file, line_num, &block)
458
+ matches = if !path
459
+ []
460
+ elsif path.readable?
461
+ [ path ]
462
+ else
463
+ Dir[ path ]
464
+ end
465
+
466
+ if matches.size > 0
467
+ matches.each { |p| yield p }
468
+ else
469
+ @logger.error "Could not resolve #{identifier} #{ '(%s, line %i)' % [source_file, line_num] if source_file && line_num }"
470
+ error_status
471
+ raise UnknownAssetError
472
+ end
473
+ end
474
+
475
+ # Log the processor's status to error
476
+ #
477
+ # ---
478
+ def error_status
479
+ lib_patterns = @options[:gem_libraries].map { |p| "$LOAD_PATH/#{p}" }
480
+ root_patterns = @options[:gem_asset_roots].map { |p| "$LOAD_PATH/#{p}" }
481
+
482
+ @logger.error "Known libraries [ %s ]: %s" %
483
+ [ lib_patterns.join(", "), libraries.keys.sort.join(", ") ]
484
+ @logger.error "Asset roots [ %s ]:\n%s" %
485
+ [ root_patterns.join(", "), asset_roots.sort.join("\n") ]
486
+ end
487
+
488
+
489
+ class << self
490
+
491
+ # Sanity check for configurations
492
+ #
493
+ # ==== Parameters
494
+ # :options<Hash>:: the configuration options
495
+ #
496
+ # ---
497
+ def verify_options(options)
498
+ # detect cases where rubygems or bundler are misconfigured
499
+ raise Error, "No load paths are available" unless $LOAD_PATH
500
+
501
+ # precaching must be turned on in order to compress
502
+ if options[:compress]
503
+ raise Error, "Must select asset precaching for compression" if options[:precache] == false
504
+ options[:precache] = true
505
+ end
506
+
507
+ # the javascript source files must be located in the public app root to have valid uris
508
+ #options[:js_source_files].each do |f|
509
+ # unless parent_path?(options[:cache_root], f)
510
+ # raise Error, "Invalid configuration: #{f} must be under app asset root"
511
+ # end
512
+ #end
513
+
514
+ # the javascript libraries in gems must be located underneath gem asset roots to have valid uris
515
+ options[:gem_libraries].each do |f|
516
+ unless options[:gem_asset_roots].any? { |r| parent_path?(r, f) }
517
+ raise Error, "Invalid configuration: #{f} is not under a valid gem asset root"
518
+ end
519
+ end
520
+ end
521
+
522
+
523
+ # Expand a list of existing files matching a globs from a set of root paths
524
+ #
525
+ # ==== Parameters
526
+ # :base_paths::
527
+ # The list of root paths
528
+ # :patterns::
529
+ # A list of unix glob-style patterns to match
530
+ #
531
+ # ==== Returns
532
+ # A list of absolute paths to existing files
533
+ #
534
+ # ---
535
+ def expand_paths(base_paths, patterns)
536
+ paths = []
537
+
538
+ base_paths.each do |base|
539
+ patterns.each do |pattern|
540
+ paths |= Dir[File.join(base, pattern)].map { |f| realpath(f) }
541
+ end
542
+ end
543
+
544
+ paths.compact
545
+ end
546
+
547
+ # Extract a javascript library name from its complete path. As for ruby
548
+ # require this is the file's basename irrespective of directory and with
549
+ # the extension left off.
550
+ #
551
+ # ==== Returns
552
+ # A string identifying the library name
553
+ #
554
+ # ---
555
+ def library_name(path)
556
+ base = path.basename.to_s
557
+ base.chomp(path.extname)
558
+ end
559
+
560
+
561
+ # Attempt to give a short name to identify the gem the provided file
562
+ # is from. If unsuccessful, return the full path again.
563
+ #
564
+ # ---
565
+ def pretty_path(path)
566
+ # default: standard rubygems repository format
567
+ pretty = path.to_s[/.*\/(gems|dirs)\/([^\/]+)\//, 2]
568
+ pretty || path
569
+ end
570
+
571
+
572
+ # Utility to check if parent path contains child path
573
+ #
574
+ # ---
575
+ def parent_path?(parent, child)
576
+ child.to_s.index(parent.to_s) == 0
577
+ end
578
+
579
+
580
+ # Utility for resolving full file paths
581
+ #
582
+ # ---
583
+ def realpath(f)
584
+ Pathname.new(f).realpath
585
+ end
586
+ end
587
+ end
588
+ end
589
+ end
@@ -0,0 +1,96 @@
1
+ require 'pathname'
2
+ require 'logger'
3
+ require 'rack/utils'
4
+ require 'fileutils'
5
+
6
+ module Repertoire
7
+ module Assets
8
+
9
+ #
10
+ # Rack middleware to serve provided files from gem roots
11
+ #
12
+ class Provides
13
+
14
+ # serve binary data from gems in blocks of this size
15
+ CHUNK_SIZE = 8192
16
+
17
+ # pattern for uris to precache (because they will appear in the manifests)
18
+ PRECACHE_EXCLUDE = /\.(js|css)$/
19
+
20
+ # Initialize the asset provider middleware.
21
+ #
22
+ # === Parameters
23
+ # :delegate::
24
+ # The next rack app in the filter chain
25
+ # :processor::
26
+ # The Repertoire Assets processor singleton
27
+ # :options::
28
+ # Hash of configuration options
29
+ # :logger::
30
+ # Framework's logger - defaults to STDERR
31
+ #
32
+ # ---
33
+ def initialize(delegate, processor, options, logger=nil)
34
+ @delegate = delegate
35
+ @processor = processor
36
+ @options = options
37
+ @logger = logger || Logger.new(STDERR)
38
+ end
39
+
40
+
41
+ # The core rack call to process an http request. If the asset has been
42
+ # required or provided by a javascript file, it is served. Otherwise
43
+ # the request is forwarded to the next rack app in the chain.
44
+ #
45
+ # ---
46
+ def call(env)
47
+ dup._call(env) || @delegate.call(env)
48
+ end
49
+
50
+ def _call(env)
51
+ uri = Rack::Utils.unescape(env["PATH_INFO"])
52
+ if @path = @processor.provided[uri]
53
+ @logger.debug "Mirroring #{uri} (#{Processor.pretty_path(@path)})"
54
+
55
+ [200, {
56
+ "Last-Modified" => @path.mtime.httpdate,
57
+ "Content-Type" => Rack::Mime.mime_type(@path.extname, 'text/plain'),
58
+ "Content-Length" => @path.size.to_s
59
+ }, self]
60
+ end
61
+ end
62
+
63
+ def each
64
+ @path.open("rb") do |file|
65
+ while part = file.read(CHUNK_SIZE)
66
+ yield part
67
+ end
68
+ end
69
+ end
70
+
71
+
72
+ # Copy all provided assets from their current locations in gems to the
73
+ # public application root. Thereafter, the web server will serve them
74
+ # directly.
75
+ #
76
+ # Javascript and css files are ignored, since they are bundled together
77
+ # by the manifest middleware.
78
+ #
79
+ # ---
80
+ def precache!
81
+ root = Pathname.new( @options[:cache_root] ).realpath
82
+
83
+ @processor.provided.each do |uri, path|
84
+ next if uri[PRECACHE_EXCLUDE]
85
+ cache_path = Pathname.new("#{root}#{uri}")
86
+
87
+ if !cache_path.exist? || path.mtime > cache_path.mtime
88
+ FileUtils.mkdir_p cache_path.dirname if !cache_path.dirname.directory?
89
+ FileUtils.cp path, cache_path
90
+ @logger.info "Cached #{uri} (#{Processor.pretty_path(path)})"
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,18 @@
1
+ require 'rails'
2
+
3
+ module Repertoire
4
+ module Assets
5
+ class Railtie < Rails::Railtie
6
+ config.repertoire_assets = ActiveSupport::OrderedOptions.new
7
+
8
+ initializer "repertoire_assets" do
9
+ config.app_middleware.use Repertoire::Assets::Processor, config.repertoire_assets, Rails.logger
10
+ end
11
+
12
+ rake_tasks do
13
+ dir = Pathname(__FILE__).dirname.expand_path
14
+ load dir + "tasks.rake"
15
+ end
16
+ end
17
+ end
18
+ end