repertoire-assets 0.2.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.
@@ -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