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