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,309 @@
1
+ require 'pathname'
2
+ require 'logger'
3
+ require 'rack/utils'
4
+
5
+ require 'stringio'
6
+ require 'open3'
7
+
8
+ # require 'nokogiri'
9
+
10
+ module Repertoire
11
+ module Assets
12
+
13
+ #
14
+ # Rack middleware to construct, interpolate, and cache js + css manifests
15
+ #
16
+ class Manifest
17
+
18
+ COMPRESSOR_PATH = Pathname.new(__FILE__).dirname + '../../vendor/yuicompressor-2.4.2.jar'
19
+ COMPRESSOR_CMD = "java -jar #{COMPRESSOR_PATH} --type %s --charset utf-8"
20
+
21
+ # regular expression for HTML 4.0 DTD through the HTML & HEAD elements
22
+ HTML_HEAD = %r{^(\s*<\s*!DOCTYPE[^>]*>)?\s*<\s*HTML[^>]*>\s*<\s*HEAD[^>]*>.*?(?=</\s*HEAD[^>]*>)}im
23
+ HEADER_BUFFER = 1000 # upper length limit for HTML_HEAD
24
+
25
+ # Initialize the rack manifest middleware.
26
+ #
27
+ # === Parameters
28
+ # :delegate::
29
+ # The next rack app in the filter chain
30
+ # :processor::
31
+ # The Repertoire Assets processor singleton
32
+ # :options::
33
+ # Hash of configuration options
34
+ # :logger::
35
+ # Framework's logger - defaults to STDERR
36
+ #
37
+ # ---
38
+ def initialize(delegate, processor, options, logger=nil)
39
+ @delegate = delegate
40
+ @processor = processor
41
+ @options = options
42
+ @logger = logger || Logger.new(STDERR)
43
+ end
44
+
45
+
46
+ # The core rack call to filter an http request. If the wrapped application
47
+ # returns an html page, the manifest is converted to <script> and <link>
48
+ # tags and interpolated into the <head>.
49
+ #
50
+ # For the middleware's purposes, an "html page" is
51
+ #
52
+ # (1) mime-type "text/html"
53
+ # (2) contains a <head> element (i.e. not an ajax html fragment)
54
+ #
55
+ # Currently, html manipulation is done using a basic HTML 4.0-compliant
56
+ # filter parser. It is simple and quite efficient.
57
+ #
58
+ # ---
59
+ def call(env)
60
+ dup._call(env)
61
+ end
62
+
63
+ def _call(env)
64
+ # get application's response
65
+ status, headers, body = @delegate.call(env)
66
+
67
+ # only reference manifest in html files
68
+ if /^text\/html/ === headers["Content-Type"]
69
+ # for html & html fragments, attempt to interpolate manifest
70
+ @path_info = Rack::Utils.unescape(env["PATH_INFO"])
71
+ @body, body = body, self
72
+ end
73
+
74
+ [status, headers, body]
75
+ end
76
+
77
+
78
+ # Apply a simple stream-based html 'filter parser' that conforms to the
79
+ # HTML 4.0 DTD to the client application's output, interpolating the html
80
+ # manifest into ^<doctype...>?<html...><head> (if it occurs within the first
81
+ # HEADER_BUFFER characters).
82
+ #
83
+ # HTML fragments are passed through unchanged.
84
+ #
85
+ # ---
86
+ def each(&block)
87
+ #
88
+ # N.B. Processing HTML using regular expressions is notoriously error-prone.
89
+ # However, after much consideration I decided that conforming to the
90
+ # HTML 4.0 DTD through <html><head> was sufficiently simple, and the
91
+ # alternatives of SAX and DOM too heavy weight for use server-side on
92
+ # every outgoing HTML page. For posterity, however, here is the code
93
+ # written in nokogiri and DOM:
94
+ #
95
+ # dom = Nokogiri::HTML(body.to_s)
96
+ # head = dom.css('head').children
97
+ # unless head.empty?
98
+ # head.before(html_manifest)
99
+ # body = dom.to_html
100
+ # end
101
+ #
102
+ prefix = ""
103
+ @body.each do |chunk|
104
+ if prefix == nil # already interpolated manifest
105
+ yield chunk
106
+ elsif prefix.size < HEADER_BUFFER # collecting enough data to interpolate manifest
107
+ prefix << chunk
108
+ else # attempt to interpolate one time only
109
+ # regular expression will match only "complete" html 4.0 documents
110
+ # fragments and all others will pass through unchanged
111
+ prefix << chunk
112
+ yield interpolate(prefix)
113
+ prefix = nil # done interpolating
114
+ end
115
+ end
116
+
117
+ # process single chunks
118
+ yield interpolate(prefix) unless prefix == nil
119
+ end
120
+
121
+
122
+ # If the manifest can be interpolated, then do so
123
+ #
124
+ def interpolate(text)
125
+ text.gsub(HTML_HEAD) do |head|
126
+ @logger.debug "Interpolating manifest into #{@path_info}"
127
+ "#{head}#{html_manifest}"
128
+ end
129
+ end
130
+
131
+
132
+ # Construct an HTML fragment containing <script> and <link> tags corresponding
133
+ # to the asset files in the manifest.
134
+ #
135
+ # To speed loading, css files are listed at the top. Javascript files are listed
136
+ # in order of their dependency requirements. Because browsers block javascript
137
+ # execution during the evaluation of a <script> element, the files will load in
138
+ # appropriate order.
139
+ #
140
+ # If the :precache option is selected, javascript and css will already
141
+ # exist in a digest at the application public root. In this case, we only
142
+ # generate a single <script> and <link> for each digest file.
143
+ #
144
+ # ---- Returns
145
+ # A string containing the HTML fragment.
146
+ #
147
+ # ---
148
+ def html_manifest
149
+ html = []
150
+ path_prefix = @options[:path_prefix] || ''
151
+
152
+ # reference digest files when caching
153
+ if @options[:precache]
154
+ digest_uri = "#{path_prefix}/#{@options[:digest_basename]}"
155
+ html << "<link rel='stylesheet' type='text/css' href='#{digest_uri}.css'/>"
156
+ html << "<script language='javascript' type='text/javascript' src='#{digest_uri}.js'></script>"
157
+ else
158
+ manifest = @processor.manifest
159
+
160
+ # output css links first since they load asynchronously
161
+ manifest.grep(/\.css$/).each do |uri|
162
+ html << "<link rel='stylesheet' type='text/css' href='#{path_prefix}#{cache_bust uri}'/>"
163
+ end
164
+
165
+ # script requires load synchronously, in order of dependencies
166
+ manifest.grep(/\.js$/).each do |uri|
167
+ html << "<script language='javascript' type='text/javascript' src='#{path_prefix}#{cache_bust uri}'></script>"
168
+ end
169
+ end
170
+
171
+ html.join("\n")
172
+ end
173
+
174
+
175
+ # Generate a URL with the file's modification time appended. This makes browsers reload when a
176
+ # static asset changes.
177
+ #
178
+ # ---- Returns
179
+ # A string containing the complete uri
180
+ #
181
+ # ---
182
+ def cache_bust(uri)
183
+ mtime = @processor.provided[uri].mtime.to_i
184
+ "#{uri}?#{mtime}"
185
+ end
186
+
187
+ # Collect all of the required js and css files from their current locations
188
+ # in gems and bundle them together into digest files stored at the application
189
+ # public root. Thereafter, the web server will serve them directly.
190
+ #
191
+ # If the :compress option is on, the YUI compressor is run on the
192
+ # digest files.
193
+ #
194
+ # Urls in CSS files are rewritten to account for the file's new URI.
195
+ #
196
+ # ---
197
+ def precache!
198
+ root = Pathname.new( @options[:cache_root] ).realpath
199
+ rewrites = {}
200
+
201
+ precache_by_type(root, 'css') do |uri, code|
202
+ # rewrite urls in css files from digest's uri at root
203
+ # see http://www.w3.org/TR/CSS21/syndata.html#uri
204
+ code.gsub(/url\(\s*['"]?(.*?)['"]?\s*\)/) do
205
+ (rewrites[uri] ||= []) << $1
206
+ "url(%s%s)" % [ @options[:path_prefix], (Pathname.new(uri).dirname + $1).cleanpath ]
207
+ end
208
+ end
209
+ precache_by_type(root, 'js')
210
+
211
+ rewrites.each { |uri, rewritten| @logger.info "Rewrote #{ rewritten.size } urls in #{ uri }" }
212
+ end
213
+
214
+
215
+ private
216
+
217
+ def precache_by_type(root, type, &block)
218
+ digest = Pathname.new(root) + "#{@options[:digest_basename]}.#{type}"
219
+ uris = @processor.manifest.grep(/\.#{type}$/)
220
+ provided = @processor.provided
221
+
222
+ # N.B. application servers often start many processes simultaneously
223
+ # only first process will write the digests since it keeps files open
224
+ return if digest.exist? && !digest.zero? && !@processor.stale?(digest.mtime)
225
+
226
+ File.open(digest, 'w') do |f|
227
+ bundled = Manifest.bundle(uris, provided) do |*args|
228
+ yield(*args) if block_given?
229
+ end
230
+ bundled = Manifest.compress(bundled, type, @logger) if @options[:compress]
231
+ f.write(bundled)
232
+ @logger.info "Cached #{digest}"
233
+ end
234
+ end
235
+
236
+
237
+ class << self
238
+
239
+ # Bundle all of the provided files of a given type into a single
240
+ # string. If a block is given, file contents are yielded to it.
241
+ #
242
+ # In the future, this will return a stream.
243
+ #
244
+ # ==== Parameters
245
+ # ::uris:
246
+ # The processor's manifest of uris for a given type, in order
247
+ # ::provided:
248
+ # The processor's hash of provided uri -> path pairs
249
+ #
250
+ # ==== Returns
251
+ # The bundled files, as a string.
252
+ # ---
253
+ def bundle(uris, provided, &block)
254
+ result = ""
255
+ uris.map do |uri|
256
+ path = provided[uri]
257
+ contents = File.read(path)
258
+ contents = yield(uri, contents) || contents if block_given?
259
+
260
+ result << "/* File: #{path} */\n"
261
+ result << contents
262
+ result << "\n\n"
263
+ end
264
+
265
+ result
266
+ end
267
+
268
+
269
+ # Utility compress files using YUI compressor.
270
+ #
271
+ # ==== Parameters
272
+ # ::source:
273
+ # The code to be compressed
274
+ # ::type:
275
+ # The type - js or css
276
+ #
277
+ # ==== Returns
278
+ # The compressed code, or source if compression failed
279
+ # ---
280
+ def compress(source, type, logger)
281
+ stream = StringIO.new(source)
282
+ Open3.popen3(COMPRESSOR_CMD % type) do |stdin, stdout, stderr|
283
+ begin
284
+ while buffer = stream.read(4096)
285
+ stdin.write(buffer)
286
+ end
287
+ stdin.close
288
+ compressed = stdout.read
289
+
290
+ if !source.empty?
291
+ raise "No result" if compressed.empty?
292
+ ratio = 100.0 * (source.length - compressed.length) / source.length
293
+ end
294
+ logger.info "Digest #{type} compression %i%% to (%ik)" %
295
+ [ratio || 0, compressed.length / 1024 ]
296
+
297
+ return compressed
298
+ rescue Exception => e
299
+ logger.warn("Could not compress: #{e.message} (using #{COMPRESSOR_CMD % type})")
300
+ logger.warn(stderr.read)
301
+ logger.warn("Reverting to uncompressed digest")
302
+ return source
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end