condenser 0.0.1
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +1 -0
- data/lib/condenser.rb +108 -0
- data/lib/condenser/asset.rb +221 -0
- data/lib/condenser/cache/memory_store.rb +92 -0
- data/lib/condenser/cache/null_store.rb +37 -0
- data/lib/condenser/context.rb +272 -0
- data/lib/condenser/encoding_utils.rb +155 -0
- data/lib/condenser/environment.rb +50 -0
- data/lib/condenser/errors.rb +11 -0
- data/lib/condenser/export.rb +68 -0
- data/lib/condenser/manifest.rb +89 -0
- data/lib/condenser/pipeline.rb +82 -0
- data/lib/condenser/processors/babel.min.js +25 -0
- data/lib/condenser/processors/babel_processor.rb +87 -0
- data/lib/condenser/processors/node_processor.rb +38 -0
- data/lib/condenser/processors/rollup.js +24083 -0
- data/lib/condenser/processors/rollup_processor.rb +164 -0
- data/lib/condenser/processors/sass_importer.rb +81 -0
- data/lib/condenser/processors/sass_processor.rb +300 -0
- data/lib/condenser/resolve.rb +202 -0
- data/lib/condenser/server.rb +307 -0
- data/lib/condenser/templating_engine/erb.rb +21 -0
- data/lib/condenser/utils.rb +32 -0
- data/lib/condenser/version.rb +3 -0
- data/lib/condenser/writers/file_writer.rb +28 -0
- data/lib/condenser/writers/zlib_writer.rb +42 -0
- data/test/cache_test.rb +24 -0
- data/test/environment_test.rb +49 -0
- data/test/manifest_test.rb +513 -0
- data/test/pipeline_test.rb +31 -0
- data/test/preprocessor/babel_test.rb +21 -0
- data/test/processors/rollup_test.rb +71 -0
- data/test/resolve_test.rb +105 -0
- data/test/server_test.rb +361 -0
- data/test/templates/erb_test.rb +18 -0
- data/test/test_helper.rb +68 -0
- data/test/transformers/scss_test.rb +49 -0
- metadata +193 -0
@@ -0,0 +1,202 @@
|
|
1
|
+
class Condenser
|
2
|
+
module Resolve
|
3
|
+
|
4
|
+
def initialize(root)
|
5
|
+
@reverse_mapping = nil
|
6
|
+
end
|
7
|
+
|
8
|
+
def resolve(filename, base=nil, accept: nil, ignore: [])
|
9
|
+
dirname, basename, extensions, mime_types = decompose_path(filename, base)
|
10
|
+
results = []
|
11
|
+
|
12
|
+
accept ||= mime_types.empty? ? ['*/*'] : mime_types
|
13
|
+
accept = Array(accept)
|
14
|
+
|
15
|
+
paths = if dirname&.start_with?('/')
|
16
|
+
if pat = path.find { |pa| dirname.start_with?(pa) }
|
17
|
+
dirname.delete_prefix!(pat)
|
18
|
+
dirname.delete_prefix!('/')
|
19
|
+
[pat]
|
20
|
+
else
|
21
|
+
[]
|
22
|
+
end
|
23
|
+
else
|
24
|
+
path
|
25
|
+
end
|
26
|
+
|
27
|
+
paths.each do |path|
|
28
|
+
glob = path
|
29
|
+
glob = File.join(glob, dirname) if dirname
|
30
|
+
glob = File.join(glob, basename)
|
31
|
+
glob << '.*' unless glob.end_with?('*')
|
32
|
+
|
33
|
+
Dir.glob(glob).sort.each do |f|
|
34
|
+
next if !File.file?(f) || ignore.include?(f)
|
35
|
+
|
36
|
+
f_dirname, f_basename, f_extensions, f_mime_types = decompose_path(f)
|
37
|
+
if (basename == '*' || basename == f_basename)
|
38
|
+
if accept == ['*/*'] || mime_type_match_accept?(f_mime_types, accept)
|
39
|
+
asset_dir = f_dirname.delete_prefix(path).delete_prefix('/')
|
40
|
+
asset_basename = f_basename + f_extensions.join('')
|
41
|
+
asset_filename = asset_dir.empty? ? asset_basename : File.join(asset_dir, asset_basename)
|
42
|
+
results << Asset.new(self, {
|
43
|
+
filename: asset_filename,
|
44
|
+
content_types: f_mime_types,
|
45
|
+
source_file: f,
|
46
|
+
source_path: path
|
47
|
+
})
|
48
|
+
end
|
49
|
+
|
50
|
+
reverse_mapping[f_mime_types]&.each do |derivative_mime_types|
|
51
|
+
if accept == ['*/*'] || mime_type_match_accept?(derivative_mime_types, accept)
|
52
|
+
asset_dir = f_dirname.delete_prefix(path).delete_prefix('/')
|
53
|
+
asset_basename = f_basename + derivative_mime_types.map { |t| @mime_types[t][:extensions].first }.join('')
|
54
|
+
asset_filename = asset_dir.empty? ? asset_basename : File.join(asset_dir, asset_basename)
|
55
|
+
results << Asset.new(self, {
|
56
|
+
filename: asset_filename,
|
57
|
+
content_types: derivative_mime_types,
|
58
|
+
source_file: f,
|
59
|
+
source_path: path
|
60
|
+
})
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
results = results.group_by do |a|
|
68
|
+
accept.find_index { |m| match_mime_types?(a.content_types, m) }
|
69
|
+
end
|
70
|
+
|
71
|
+
results = results.keys.sort.reduce([]) do |c, key|
|
72
|
+
c += results[key].sort_by(&:filename)
|
73
|
+
end
|
74
|
+
|
75
|
+
results = results.map { |a| a.basepath }.uniq.map {|fn| results.find {|r| r.filename.sub(/\.(\w+)$/, '') == fn}}
|
76
|
+
|
77
|
+
results.sort_by(&:filename)
|
78
|
+
end
|
79
|
+
|
80
|
+
def resolve!(filename, base=nil, **kargs)
|
81
|
+
assets = resolve(filename, base, **kargs)
|
82
|
+
if assets.empty?
|
83
|
+
raise FileNotFound, "couldn't find file '#{filename}'"
|
84
|
+
else
|
85
|
+
assets
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def find(filename, base=nil, **kargs)
|
90
|
+
resolve(filename, base, **kargs).first
|
91
|
+
end
|
92
|
+
|
93
|
+
def find!(filename, base=nil, **kargs)
|
94
|
+
resolve!(filename, base, **kargs).first
|
95
|
+
end
|
96
|
+
|
97
|
+
def [](filename)
|
98
|
+
find!(filename).export
|
99
|
+
end
|
100
|
+
|
101
|
+
def find_export(filename, base=nil, **kargs)
|
102
|
+
asset = resolve(filename, base, **kargs).first
|
103
|
+
asset&.export
|
104
|
+
end
|
105
|
+
|
106
|
+
|
107
|
+
def decompose_path(path, base=nil)
|
108
|
+
dirname = path.index('/') ? File.dirname(path) : nil
|
109
|
+
if base
|
110
|
+
dirname = File.expand_path(dirname, base)
|
111
|
+
end
|
112
|
+
_, basename, extensions = path.match(/([^\.\/]+)(\.[^\/]*)?$/).to_a
|
113
|
+
|
114
|
+
if extensions.nil? && basename == '*'
|
115
|
+
extensions = nil
|
116
|
+
mime_types = []
|
117
|
+
elsif extensions.nil?
|
118
|
+
mime_types = []
|
119
|
+
else
|
120
|
+
exts = []
|
121
|
+
while !extensions.empty?
|
122
|
+
matching_extensions = @extensions.keys.select { |e| extensions.end_with?(e) }
|
123
|
+
if matching_extensions.empty?
|
124
|
+
basename << extensions
|
125
|
+
break
|
126
|
+
# raise 'unkown mime'
|
127
|
+
else
|
128
|
+
matching_extensions.sort_by! { |e| -e.length }
|
129
|
+
exts.unshift(matching_extensions.first)
|
130
|
+
extensions.delete_suffix!(matching_extensions.first)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
extensions = exts
|
134
|
+
mime_types = extensions.map { |k| @extensions[k] }
|
135
|
+
end
|
136
|
+
|
137
|
+
|
138
|
+
[ dirname, basename, extensions, mime_types ]
|
139
|
+
end
|
140
|
+
|
141
|
+
def reverse_mapping
|
142
|
+
return @reverse_mapping if @reverse_mapping
|
143
|
+
map = {}
|
144
|
+
@mime_types.each_key do |source_mime_type|
|
145
|
+
to_mime_type = source_mime_type
|
146
|
+
|
147
|
+
([nil] + (@transformers[source_mime_type]&.keys || [])).each do |transform_mime_type|
|
148
|
+
to_mime_type = transform_mime_type if transform_mime_type
|
149
|
+
|
150
|
+
([nil] + @templates.keys).each do |template_mime_type|
|
151
|
+
from_mimes = [source_mime_type, template_mime_type].compact
|
152
|
+
to_mime_types = [to_mime_type].compact
|
153
|
+
if from_mimes != to_mime_types
|
154
|
+
map[from_mimes] ||= Set.new
|
155
|
+
map[from_mimes] << to_mime_types
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
end
|
160
|
+
end
|
161
|
+
$map = map
|
162
|
+
end
|
163
|
+
|
164
|
+
def writers_for_mime_type(mime_type)
|
165
|
+
@writers.select { |m, e| match_mime_type?(mime_type, m) }.values.reduce(&:+)
|
166
|
+
end
|
167
|
+
|
168
|
+
def match_mime_types?(value, matcher)
|
169
|
+
matcher = Array(matcher)
|
170
|
+
value = Array(value)
|
171
|
+
|
172
|
+
if matcher.length == 1 && matcher.last == '*/*'
|
173
|
+
true
|
174
|
+
else
|
175
|
+
value.length == matcher.length && value.zip(matcher).all? { |v, m| match_mime_type?(v, m) }
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
def mime_type_match_accept?(value, accept)
|
180
|
+
accept.any? do |a|
|
181
|
+
match_mime_types?(value, Array(a))
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# Public: Test mime type against mime range.
|
186
|
+
#
|
187
|
+
# match_mime_type?('text/html', 'text/*') => true
|
188
|
+
# match_mime_type?('text/plain', '*') => true
|
189
|
+
# match_mime_type?('text/html', 'application/json') => false
|
190
|
+
#
|
191
|
+
# Returns true if the given value is a mime match for the given mime match
|
192
|
+
# specification, false otherwise.
|
193
|
+
def match_mime_type?(value, matcher)
|
194
|
+
v1, v2 = value.split('/'.freeze, 2)
|
195
|
+
m1, m2 = matcher.split('/'.freeze, 2)
|
196
|
+
(m1 == '*'.freeze || v1 == m1) && (m2.nil? || m2 == '*'.freeze || m2 == v2)
|
197
|
+
end
|
198
|
+
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
|
@@ -0,0 +1,307 @@
|
|
1
|
+
require 'set'
|
2
|
+
require 'time'
|
3
|
+
require 'rack/utils'
|
4
|
+
|
5
|
+
class Condenser
|
6
|
+
class Server
|
7
|
+
|
8
|
+
ALLOWED_REQUEST_METHODS = ['GET', 'HEAD'].to_set.freeze
|
9
|
+
|
10
|
+
def initialize(condenser)
|
11
|
+
@condenser = condenser
|
12
|
+
end
|
13
|
+
|
14
|
+
def logger
|
15
|
+
@condenser.logger
|
16
|
+
end
|
17
|
+
|
18
|
+
# `call` implements the Rack 1.x specification which accepts an
|
19
|
+
# `env` Hash and returns a three item tuple with the status code,
|
20
|
+
# headers, and body.
|
21
|
+
#
|
22
|
+
# Mapping your environment at a url prefix will serve all assets
|
23
|
+
# in the path.
|
24
|
+
#
|
25
|
+
# map "/assets" do
|
26
|
+
# run Condenser::Server.new(condenser)
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# A request for `"/assets/foo/bar.js"` will search your
|
30
|
+
# environment for `"foo/bar.js"`.
|
31
|
+
def call(env)
|
32
|
+
start_time = Time.now.to_f
|
33
|
+
time_elapsed = lambda { ((Time.now.to_f - start_time) * 1000).to_i }
|
34
|
+
|
35
|
+
if !ALLOWED_REQUEST_METHODS.include?(env['REQUEST_METHOD'])
|
36
|
+
return method_not_allowed_response
|
37
|
+
end
|
38
|
+
|
39
|
+
msg = "Served asset #{env['PATH_INFO']} -"
|
40
|
+
|
41
|
+
# Extract the path from everything after the leading slash
|
42
|
+
path = Rack::Utils.unescape(env['PATH_INFO'].to_s.sub(/^\//, ''))
|
43
|
+
|
44
|
+
if !path.valid_encoding?
|
45
|
+
return bad_request_response(env)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Strip fingerprint
|
49
|
+
if fingerprint = path_fingerprint(path)
|
50
|
+
path = path.sub("-#{fingerprint}", '')
|
51
|
+
end
|
52
|
+
|
53
|
+
# URLs containing a `".."` are rejected for security reasons.
|
54
|
+
if forbidden_request?(path)
|
55
|
+
return forbidden_response(env)
|
56
|
+
end
|
57
|
+
|
58
|
+
if fingerprint
|
59
|
+
if_match = fingerprint
|
60
|
+
elsif env['HTTP_IF_MATCH']
|
61
|
+
if_match = env['HTTP_IF_MATCH'][/"(\w+)"$/, 1]
|
62
|
+
end
|
63
|
+
|
64
|
+
if env['HTTP_IF_NONE_MATCH']
|
65
|
+
if_none_match = env['HTTP_IF_NONE_MATCH'][/"(\w+)"$/, 1]
|
66
|
+
end
|
67
|
+
|
68
|
+
# Look up the asset.
|
69
|
+
asset = @condenser.find_export(path)
|
70
|
+
|
71
|
+
if asset.nil?
|
72
|
+
status = :not_found
|
73
|
+
elsif fingerprint && asset.etag != fingerprint
|
74
|
+
status = :not_found
|
75
|
+
elsif if_match && asset.etag != if_match
|
76
|
+
status = :precondition_failed
|
77
|
+
elsif if_none_match && asset.etag == if_none_match
|
78
|
+
status = :not_modified
|
79
|
+
else
|
80
|
+
status = :ok
|
81
|
+
end
|
82
|
+
|
83
|
+
case status
|
84
|
+
when :ok
|
85
|
+
logger.info "#{msg} 200 OK (#{time_elapsed.call}ms)"
|
86
|
+
ok_response(asset, env)
|
87
|
+
when :not_modified
|
88
|
+
logger.info "#{msg} 304 Not Modified (#{time_elapsed.call}ms)"
|
89
|
+
not_modified_response(env, if_none_match)
|
90
|
+
when :not_found
|
91
|
+
logger.info "#{msg} 404 Not Found (#{time_elapsed.call}ms)"
|
92
|
+
not_found_response(env)
|
93
|
+
when :precondition_failed
|
94
|
+
logger.info "#{msg} 412 Precondition Failed (#{time_elapsed.call}ms)"
|
95
|
+
precondition_failed_response(env)
|
96
|
+
end
|
97
|
+
rescue StandardError => e
|
98
|
+
logger.error "Error compiling asset #{path}:"
|
99
|
+
logger.error "#{e.class.name}: #{e.message}"
|
100
|
+
|
101
|
+
case File.extname(path)
|
102
|
+
when ".js"
|
103
|
+
# Re-throw JavaScript asset exceptions to the browser
|
104
|
+
logger.info "#{msg} 500 Internal Server Error\n\n"
|
105
|
+
return javascript_exception_response(e)
|
106
|
+
when ".css"
|
107
|
+
# Display CSS asset exceptions in the browser
|
108
|
+
logger.info "#{msg} 500 Internal Server Error\n\n"
|
109
|
+
return css_exception_response(e)
|
110
|
+
else
|
111
|
+
raise
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
private
|
116
|
+
def forbidden_request?(path)
|
117
|
+
# Prevent access to files elsewhere on the file system
|
118
|
+
#
|
119
|
+
# http://example.org/assets/../../../etc/passwd
|
120
|
+
#
|
121
|
+
path.include?("..") || absolute_path?(path)
|
122
|
+
end
|
123
|
+
|
124
|
+
def head_request?(env)
|
125
|
+
env['REQUEST_METHOD'] == 'HEAD'
|
126
|
+
end
|
127
|
+
|
128
|
+
# Returns a 200 OK response tuple
|
129
|
+
def ok_response(asset, env)
|
130
|
+
if head_request?(env)
|
131
|
+
[ 200, headers(env, asset, 0), [] ]
|
132
|
+
else
|
133
|
+
[ 200, headers(env, asset, asset.length), [asset.to_s] ]
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Returns a 304 Not Modified response tuple
|
138
|
+
def not_modified_response(env, etag)
|
139
|
+
[ 304, cache_headers(env, etag), [] ]
|
140
|
+
end
|
141
|
+
|
142
|
+
# Returns a 400 Forbidden response tuple
|
143
|
+
def bad_request_response(env)
|
144
|
+
if head_request?(env)
|
145
|
+
[ 400, { "Content-Type" => "text/plain", "Content-Length" => "0" }, [] ]
|
146
|
+
else
|
147
|
+
[ 400, { "Content-Type" => "text/plain", "Content-Length" => "11" }, [ "Bad Request" ] ]
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Returns a 403 Forbidden response tuple
|
152
|
+
def forbidden_response(env)
|
153
|
+
if head_request?(env)
|
154
|
+
[ 403, { "Content-Type" => "text/plain", "Content-Length" => "0" }, [] ]
|
155
|
+
else
|
156
|
+
[ 403, { "Content-Type" => "text/plain", "Content-Length" => "9" }, [ "Forbidden" ] ]
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Returns a 404 Not Found response tuple
|
161
|
+
def not_found_response(env)
|
162
|
+
if head_request?(env)
|
163
|
+
[ 404, { "Content-Type" => "text/plain", "Content-Length" => "0", "X-Cascade" => "pass" }, [] ]
|
164
|
+
else
|
165
|
+
[ 404, { "Content-Type" => "text/plain", "Content-Length" => "9", "X-Cascade" => "pass" }, [ "Not found" ] ]
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def method_not_allowed_response
|
170
|
+
[ 405, { "Content-Type" => "text/plain", "Content-Length" => "18" }, [ "Method Not Allowed" ] ]
|
171
|
+
end
|
172
|
+
|
173
|
+
def precondition_failed_response(env)
|
174
|
+
if head_request?(env)
|
175
|
+
[ 412, { "Content-Type" => "text/plain", "Content-Length" => "0", "X-Cascade" => "pass" }, [] ]
|
176
|
+
else
|
177
|
+
[ 412, { "Content-Type" => "text/plain", "Content-Length" => "19", "X-Cascade" => "pass" }, [ "Precondition Failed" ] ]
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Returns a JavaScript response that re-throws a Ruby exception
|
182
|
+
# in the browser
|
183
|
+
def javascript_exception_response(exception)
|
184
|
+
err = "#{exception.class.name}: #{exception.message}\n (in #{exception.backtrace[0]})"
|
185
|
+
body = "throw Error(#{err.inspect})"
|
186
|
+
[ 200, { "Content-Type" => "application/javascript", "Content-Length" => body.bytesize.to_s }, [ body ] ]
|
187
|
+
end
|
188
|
+
|
189
|
+
# Returns a CSS response that hides all elements on the page and
|
190
|
+
# displays the exception
|
191
|
+
def css_exception_response(exception)
|
192
|
+
message = "\n#{exception.class.name}: #{exception.message}"
|
193
|
+
backtrace = "\n #{exception.backtrace.first}"
|
194
|
+
|
195
|
+
body = <<-CSS
|
196
|
+
html {
|
197
|
+
padding: 18px 36px;
|
198
|
+
}
|
199
|
+
|
200
|
+
head {
|
201
|
+
display: block;
|
202
|
+
}
|
203
|
+
|
204
|
+
body {
|
205
|
+
margin: 0;
|
206
|
+
padding: 0;
|
207
|
+
}
|
208
|
+
|
209
|
+
body > * {
|
210
|
+
display: none !important;
|
211
|
+
}
|
212
|
+
|
213
|
+
head:after, body:before, body:after {
|
214
|
+
display: block !important;
|
215
|
+
}
|
216
|
+
|
217
|
+
head:after {
|
218
|
+
font-family: sans-serif;
|
219
|
+
font-size: large;
|
220
|
+
font-weight: bold;
|
221
|
+
content: "Error compiling CSS asset";
|
222
|
+
}
|
223
|
+
|
224
|
+
body:before, body:after {
|
225
|
+
font-family: monospace;
|
226
|
+
white-space: pre-wrap;
|
227
|
+
}
|
228
|
+
|
229
|
+
body:before {
|
230
|
+
font-weight: bold;
|
231
|
+
content: "#{escape_css_content(message)}";
|
232
|
+
}
|
233
|
+
|
234
|
+
body:after {
|
235
|
+
content: "#{escape_css_content(backtrace)}";
|
236
|
+
}
|
237
|
+
CSS
|
238
|
+
|
239
|
+
[ 200, { "Content-Type" => "text/css; charset=utf-8", "Content-Length" => body.bytesize.to_s }, [ body ] ]
|
240
|
+
end
|
241
|
+
|
242
|
+
# Escape special characters for use inside a CSS content("...") string
|
243
|
+
def escape_css_content(content)
|
244
|
+
content.
|
245
|
+
gsub('\\', '\\\\005c ').
|
246
|
+
gsub("\n", '\\\\000a ').
|
247
|
+
gsub('"', '\\\\0022 ').
|
248
|
+
gsub('/', '\\\\002f ')
|
249
|
+
end
|
250
|
+
|
251
|
+
def cache_headers(env, etag)
|
252
|
+
headers = {}
|
253
|
+
|
254
|
+
# Set caching headers
|
255
|
+
headers["Cache-Control"] = String.new("public")
|
256
|
+
headers["ETag"] = %("#{etag}")
|
257
|
+
|
258
|
+
# If the request url contains a fingerprint, set a long
|
259
|
+
# expires on the response
|
260
|
+
if path_fingerprint(env["PATH_INFO"])
|
261
|
+
headers["Cache-Control"] << ", max-age=31536000, immutable"
|
262
|
+
|
263
|
+
# Otherwise set `must-revalidate` since the asset could be modified.
|
264
|
+
else
|
265
|
+
headers["Cache-Control"] << ", must-revalidate"
|
266
|
+
headers["Vary"] = "Accept-Encoding"
|
267
|
+
end
|
268
|
+
|
269
|
+
headers
|
270
|
+
end
|
271
|
+
|
272
|
+
def headers(env, asset, length)
|
273
|
+
headers = {}
|
274
|
+
|
275
|
+
# Set content length header
|
276
|
+
headers["Content-Length"] = length.to_s
|
277
|
+
|
278
|
+
if asset&.sourcemap
|
279
|
+
headers['SourceMap'] = env['SCRIPT_NAME'] + env['PATH_INFO'] + '.map'
|
280
|
+
end
|
281
|
+
|
282
|
+
# Set content type header
|
283
|
+
if type = asset.content_type
|
284
|
+
# Set charset param for text/* mime types
|
285
|
+
if type.start_with?("text/") && asset.charset
|
286
|
+
type += "; charset=#{asset.charset}"
|
287
|
+
end
|
288
|
+
headers["Content-Type"] = type
|
289
|
+
end
|
290
|
+
|
291
|
+
headers.merge(cache_headers(env, asset.etag))
|
292
|
+
end
|
293
|
+
|
294
|
+
# Gets ETag fingerprint.
|
295
|
+
#
|
296
|
+
# "foo-0aa2105d29558f3eb790d411d7d8fb66.js"
|
297
|
+
# # => "0aa2105d29558f3eb790d411d7d8fb66"
|
298
|
+
#
|
299
|
+
def path_fingerprint(path)
|
300
|
+
path[/-([0-9a-f]{7,128})\.[^.]+\z/, 1]
|
301
|
+
end
|
302
|
+
|
303
|
+
def absolute_path?(path)
|
304
|
+
path.start_with?(File::SEPARATOR)
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|