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,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