repertoire-assets 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +5 -0
- data/FAQ +90 -0
- data/INSTALL +35 -0
- data/LICENSE +22 -0
- data/README +234 -0
- data/Rakefile +10 -0
- data/TODO +52 -0
- data/lib/repertoire-assets/exceptions.rb +8 -0
- data/lib/repertoire-assets/manifest.rb +309 -0
- data/lib/repertoire-assets/processor.rb +589 -0
- data/lib/repertoire-assets/provides.rb +96 -0
- data/lib/repertoire-assets/railtie.rb +18 -0
- data/lib/repertoire-assets/tasks.rake +29 -0
- data/lib/repertoire-assets/version.rb +5 -0
- data/lib/repertoire-assets.rb +11 -0
- data/repertoire-assets.gemspec +56 -0
- data/vendor/yuicompressor-2.4.2.jar +0 -0
- metadata +96 -0
@@ -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
|