darkroom 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +16 -0
- data/README.md +139 -0
- data/VERSION +1 -0
- data/lib/darkroom.rb +39 -0
- data/lib/darkroom/asset.rb +300 -0
- data/lib/darkroom/darkroom.rb +228 -0
- data/lib/darkroom/errors/asset_not_found_error.rb +33 -0
- data/lib/darkroom/errors/duplicate_asset_error.rb +30 -0
- data/lib/darkroom/errors/missing_library_error.rb +30 -0
- data/lib/darkroom/errors/processing_error.rb +31 -0
- data/lib/darkroom/errors/spec_not_defined_error.rb +28 -0
- data/lib/darkroom/version.rb +5 -0
- metadata +92 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 15822eea03a5814dc169523ec7285c97b12efa7e3818a5de03099b670823564d
|
4
|
+
data.tar.gz: 6ab19e03a8282508ea571d1d30b458750aaad273af0151d2582eeff889d69a4e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a421ab802832dd3268d8fdb17cf1c3843f5bf554ec43eace104b3e324a53bf4790f5268a65b4c1acb5b545782ab69fda5ea5aeebca62b77f3cc1ebe4889aa5d7
|
7
|
+
data.tar.gz: b040b997cbc321ce11af041f387bb1a89543071b9b657a38575602e8663e87573ff6442af8c016061ce29fdb67284f5301cbcbaa22b14cd8c8d1ce6a61b255db
|
data/LICENSE
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
Copyright 2021 Nate Pickens
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
4
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without
|
5
|
+
limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
6
|
+
the Software, and to permit persons to whom the Software is furnished to do so, subject to the following
|
7
|
+
conditions:
|
8
|
+
|
9
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
10
|
+
portions of the Software.
|
11
|
+
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
13
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
14
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
15
|
+
AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
16
|
+
OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
# Darkroom
|
2
|
+
|
3
|
+
Darkroom is a fast, lightweight, and straightforward web asset management library. Processed assets are all
|
4
|
+
stored in and served directly from memory rather than being written to disk (though a dump to disk can be
|
5
|
+
performed for upload to a CDN or proxy server in production environments); this keeps asset management
|
6
|
+
simple and performant in development. Darkroom also supports asset bundling for CSS and JavaScript using
|
7
|
+
each language's native import statement syntax.
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add this line to your Gemfile:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
gem('darkroom')
|
15
|
+
```
|
16
|
+
|
17
|
+
Or install manually on the command line:
|
18
|
+
|
19
|
+
```bash
|
20
|
+
gem install darkroom
|
21
|
+
```
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
|
25
|
+
To create and start using a Darkroom instance, specify one or more load paths (all other arguments are
|
26
|
+
optional):
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
darkroom = Darkroom.new('app/assets', 'vendor/assets', '...',
|
30
|
+
hosts: ['https://cdn1.com', '...'] # Hosts to prepend to asset paths (useful in production)
|
31
|
+
prefix: '/static', # Prefix to add to all asset paths
|
32
|
+
pristine: ['/google-verify.html'], # Paths with no prefix or versioning (e.g. /favicon.ico)
|
33
|
+
minify: true, # Minify assets that can be minified
|
34
|
+
minified_pattern: /(\.|-)min\.\w+$/, # Files that should not be minified
|
35
|
+
internal_pattern: /^\/components\//, # Files that cannot be accessed directly
|
36
|
+
min_process_interval: 1, # Minimum time that must elapse between process calls
|
37
|
+
)
|
38
|
+
|
39
|
+
# Refreshe any assets that have been modified (in development, this should be called at the
|
40
|
+
# beginning of each web request).
|
41
|
+
darkroom.process
|
42
|
+
|
43
|
+
# Dump assets to disk. Useful when deploying to a production environment where assets will be
|
44
|
+
# uploaded to and served from a CDN or proxy server.
|
45
|
+
darkroom.dump('output/dir',
|
46
|
+
clear: true, # Delete contents of output/dir before dumping
|
47
|
+
include_pristine: true, # Include pristine assets (if preparing for CDN upload, files like
|
48
|
+
) # /favicon.ico or /robots.txt should be left out)
|
49
|
+
```
|
50
|
+
|
51
|
+
Note that assets paths across all load path directories must be globally unique (e.g. the existance of both
|
52
|
+
`app/assets/app.js` and `vendor/assets/app.js` will result in an error).
|
53
|
+
|
54
|
+
To work with assets:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
# Get the external path that will be used by HTTP requests.
|
58
|
+
path = darkroom.asset_path('/js/app.js') # => '/static/js/app-<fingerprint>.js'
|
59
|
+
|
60
|
+
# Retrieve the Asset object associated with a path.
|
61
|
+
asset = darkroom.asset(path)
|
62
|
+
|
63
|
+
# Getting paths directly from an Asset object will not include any host or prefix.
|
64
|
+
assest.path # => '/js/app.js'
|
65
|
+
assest.path_versioned # => '/js/app-<fingerprint>.js'
|
66
|
+
|
67
|
+
asset.content_type # => 'application/javascript'
|
68
|
+
asset.content # Content of processed /js/app.js file
|
69
|
+
|
70
|
+
asset.headers # => {'Content-Type' => 'application/javascript',
|
71
|
+
# 'Cache-Control' => 'public, max-age=31536000'}
|
72
|
+
asset.headers(versioned: false) # => {'Content-Type' => 'application/javascript',
|
73
|
+
# 'ETag' => '<fingerprint>'}
|
74
|
+
```
|
75
|
+
|
76
|
+
## Asset Bundling
|
77
|
+
|
78
|
+
CSS and JavaScript assets specify their dependencies by way of each language's native import statement. Each
|
79
|
+
import statement is replaced with content of the referenced asset. Example:
|
80
|
+
|
81
|
+
```javascript
|
82
|
+
// Unprocessed /api.js
|
83
|
+
function api() {
|
84
|
+
console.log('API called!')
|
85
|
+
}
|
86
|
+
|
87
|
+
// Unprocessed /app.js
|
88
|
+
import '/api.js'
|
89
|
+
|
90
|
+
api()
|
91
|
+
|
92
|
+
// Processed /app.js
|
93
|
+
function api() {
|
94
|
+
console.log('API called!')
|
95
|
+
}
|
96
|
+
|
97
|
+
|
98
|
+
api()
|
99
|
+
```
|
100
|
+
|
101
|
+
The same applies for CSS files. Example:
|
102
|
+
|
103
|
+
```css
|
104
|
+
/* Unprocessed /header.css */
|
105
|
+
header {
|
106
|
+
background: #f1f1f1;
|
107
|
+
}
|
108
|
+
|
109
|
+
/* Unprocessed /app.css */
|
110
|
+
@import '/header.css';
|
111
|
+
|
112
|
+
body {
|
113
|
+
background: #fff;
|
114
|
+
}
|
115
|
+
|
116
|
+
/* Processed /app.css */
|
117
|
+
header {
|
118
|
+
background: #f1f1f1;
|
119
|
+
}
|
120
|
+
|
121
|
+
|
122
|
+
body {
|
123
|
+
background: #fff;
|
124
|
+
}
|
125
|
+
```
|
126
|
+
|
127
|
+
Imported assets can also contain import statements, and those assets are all included in the base asset.
|
128
|
+
Imports can even be cyclical. If `asset-a.css` imports `asset-b.css` and vice-versa, each asset will simply
|
129
|
+
contain the content of both of those assets (though order will be different as an asset's own content always
|
130
|
+
comes after any imported assets' contents).
|
131
|
+
|
132
|
+
## Contributing
|
133
|
+
|
134
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/npickens/darkroom.
|
135
|
+
|
136
|
+
## License
|
137
|
+
|
138
|
+
The gem is available as open source under the terms of the
|
139
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.0.1
|
data/lib/darkroom.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require('darkroom/asset')
|
4
|
+
require('darkroom/darkroom')
|
5
|
+
require('darkroom/version')
|
6
|
+
|
7
|
+
require('darkroom/errors/asset_not_found_error')
|
8
|
+
require('darkroom/errors/duplicate_asset_error')
|
9
|
+
require('darkroom/errors/missing_library_error')
|
10
|
+
require('darkroom/errors/processing_error')
|
11
|
+
require('darkroom/errors/spec_not_defined_error')
|
12
|
+
|
13
|
+
Darkroom::Asset.add_spec('.css', 'text/css',
|
14
|
+
dependency_regex: /^ *@import +(?<quote>['"]) *(?<path>.*) *\g<quote> *; *$/,
|
15
|
+
minify: -> (content) { CSSminify.compress(content) },
|
16
|
+
minify_lib: 'cssminify',
|
17
|
+
)
|
18
|
+
|
19
|
+
Darkroom::Asset.add_spec('.js', 'application/javascript',
|
20
|
+
dependency_regex: /^ *import +(?<quote>['"])(?<path>.*)\g<quote> *;? *$/,
|
21
|
+
minify: -> (content) { Uglifier.compile(content, harmony: true) },
|
22
|
+
minify_lib: 'uglifier',
|
23
|
+
)
|
24
|
+
|
25
|
+
Darkroom::Asset.add_spec('.htx', 'application/javascript',
|
26
|
+
compile: -> (path, content) { HTX.compile(path, content) },
|
27
|
+
compile_lib: 'htx',
|
28
|
+
minify: Darkroom::Asset.spec('.js').minify,
|
29
|
+
minify_lib: Darkroom::Asset.spec('.js').minify_lib,
|
30
|
+
)
|
31
|
+
|
32
|
+
Darkroom::Asset.add_spec('.html', '.html', 'text/html')
|
33
|
+
Darkroom::Asset.add_spec('.ico', 'image/x-icon')
|
34
|
+
Darkroom::Asset.add_spec('.jpg', '.jpeg', 'image/jpeg')
|
35
|
+
Darkroom::Asset.add_spec('.png', 'image/png')
|
36
|
+
Darkroom::Asset.add_spec('.svg', 'image/svg+xml')
|
37
|
+
Darkroom::Asset.add_spec('.txt', 'text/plain')
|
38
|
+
Darkroom::Asset.add_spec('.woff', 'font/woff')
|
39
|
+
Darkroom::Asset.add_spec('.woff2', 'font/woff2')
|
@@ -0,0 +1,300 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require('digest')
|
4
|
+
|
5
|
+
class Darkroom
|
6
|
+
##
|
7
|
+
# Represents an asset.
|
8
|
+
#
|
9
|
+
class Asset
|
10
|
+
DEPENDENCY_JOINER = "\n"
|
11
|
+
EXTENSION_REGEX = /(?=\.\w+)/.freeze
|
12
|
+
|
13
|
+
@@specs = {}
|
14
|
+
|
15
|
+
attr_reader(:content, :error, :errors, :path, :path_versioned)
|
16
|
+
|
17
|
+
##
|
18
|
+
# Holds information about how to handle a particular asset type.
|
19
|
+
#
|
20
|
+
# * +content_type+ - HTTP MIME type string.
|
21
|
+
# * +dependency_regex+ - Regex to match lines of the file against to find dependencies. Must contain a
|
22
|
+
# named component called 'path' (e.g. +/^import (?<path>.*)/+).
|
23
|
+
# * +compile+ - Proc to call that will produce the compiled version of the asset's content.
|
24
|
+
# * +compile_lib+ - Name of a library to +require+ that is needed by the +compile+ proc.
|
25
|
+
# * +minify+ - Proc to call that will produce the minified version of the asset's content.
|
26
|
+
# * +minify_lib+ - Name of a library to +require+ that is needed by the +minify+ proc.
|
27
|
+
#
|
28
|
+
Spec = Struct.new(:content_type, :dependency_regex, :compile, :compile_lib, :minify, :minify_lib)
|
29
|
+
|
30
|
+
##
|
31
|
+
# Defines an asset spec.
|
32
|
+
#
|
33
|
+
# * +extensions+ - File extensions to associate with this spec.
|
34
|
+
# * +content_type+ - HTTP MIME type string.
|
35
|
+
# * +other+ - Optional components of the spec (see Spec struct).
|
36
|
+
#
|
37
|
+
def self.add_spec(*extensions, content_type, **other)
|
38
|
+
spec = Spec.new(
|
39
|
+
content_type.freeze,
|
40
|
+
other[:dependency_regex].freeze,
|
41
|
+
other[:compile].freeze,
|
42
|
+
other[:compile_lib].freeze,
|
43
|
+
other[:minify].freeze,
|
44
|
+
other[:minify_lib].freeze,
|
45
|
+
).freeze
|
46
|
+
|
47
|
+
extensions.each do |extension|
|
48
|
+
@@specs[extension] = spec
|
49
|
+
end
|
50
|
+
|
51
|
+
spec
|
52
|
+
end
|
53
|
+
|
54
|
+
##
|
55
|
+
# Returns the spec associated with a file extension.
|
56
|
+
#
|
57
|
+
# * +extension+ - File extension of the desired spec.
|
58
|
+
#
|
59
|
+
def self.spec(extension)
|
60
|
+
@@specs[extension]
|
61
|
+
end
|
62
|
+
|
63
|
+
##
|
64
|
+
# Returns an array of file extensions for which specs exist.
|
65
|
+
#
|
66
|
+
def self.extensions
|
67
|
+
@@specs.keys
|
68
|
+
end
|
69
|
+
|
70
|
+
##
|
71
|
+
# Creates a new instance.
|
72
|
+
#
|
73
|
+
# * +file+ - The path to the file on disk.
|
74
|
+
# * +path+ - The path this asset will be referenced by (e.g. /js/app.js).
|
75
|
+
# * +manifest+ - Manifest hash from the Darkroom instance that the asset is a member of.
|
76
|
+
# * +minify+ - Boolean specifying whether or not the asset should be minified when processed.
|
77
|
+
# * +internal+ - Boolean indicating whether or not the asset is only accessible internally (i.e. as a
|
78
|
+
# dependency).
|
79
|
+
#
|
80
|
+
def initialize(path, file, manifest, minify: false, internal: false)
|
81
|
+
@path = path
|
82
|
+
@file = file
|
83
|
+
@manifest = manifest
|
84
|
+
@minify = minify
|
85
|
+
@internal = internal
|
86
|
+
|
87
|
+
@extension = File.extname(@path).downcase
|
88
|
+
@spec = self.class.spec(@extension) or raise(SpecNotDefinedError.new(@extension, @file))
|
89
|
+
|
90
|
+
require_libs
|
91
|
+
clear
|
92
|
+
end
|
93
|
+
|
94
|
+
##
|
95
|
+
# Processes the asset if necessary (file's mtime is compared to the last time it was processed). File is
|
96
|
+
# read from disk, any dependencies are merged into its content (if spec for the asset type allows for
|
97
|
+
# it), the content is compiled (if the asset type requires compilation), and minified (if specified for
|
98
|
+
# this Asset). Returns true if asset was modified since it was last processed and false otherwise.
|
99
|
+
#
|
100
|
+
# * +key+ - Unique value associated with the current round of processing.
|
101
|
+
#
|
102
|
+
def process(key)
|
103
|
+
key == @process_key ? (return @modified) : (@process_key = key)
|
104
|
+
|
105
|
+
begin
|
106
|
+
@modified = @mtime != (@mtime = File.mtime(@file))
|
107
|
+
@modified ||= @dependencies.any? { |d| d.process(key) }
|
108
|
+
|
109
|
+
return false unless @modified
|
110
|
+
rescue Errno::ENOENT
|
111
|
+
clear
|
112
|
+
return @modified = true
|
113
|
+
end
|
114
|
+
|
115
|
+
clear
|
116
|
+
read(key)
|
117
|
+
compile
|
118
|
+
minify
|
119
|
+
|
120
|
+
@fingerprint = Digest::MD5.hexdigest(@content)
|
121
|
+
@path_versioned = @path.sub(EXTENSION_REGEX, "-#{@fingerprint}")
|
122
|
+
|
123
|
+
@modified
|
124
|
+
ensure
|
125
|
+
@error = @errors.empty? ? nil : ProcessingError.new(@errors)
|
126
|
+
end
|
127
|
+
|
128
|
+
##
|
129
|
+
# Returns the HTTP MIME type string.
|
130
|
+
#
|
131
|
+
def content_type
|
132
|
+
@spec.content_type
|
133
|
+
end
|
134
|
+
|
135
|
+
##
|
136
|
+
# Returns appropriate HTTP headers.
|
137
|
+
#
|
138
|
+
# * +versioned+ - Uses Cache-Control header with max-age if +true+ and ETag header if +false+.
|
139
|
+
#
|
140
|
+
def headers(versioned: true)
|
141
|
+
{
|
142
|
+
'Content-Type' => content_type,
|
143
|
+
'Cache-Control' => ('public, max-age=31536000' if versioned),
|
144
|
+
'ETag' => ("\"#{@fingerprint}\"" if !versioned),
|
145
|
+
}.compact!
|
146
|
+
end
|
147
|
+
|
148
|
+
##
|
149
|
+
# Returns boolean indicating whether or not the asset is marked as internal.
|
150
|
+
#
|
151
|
+
def internal?
|
152
|
+
@internal
|
153
|
+
end
|
154
|
+
|
155
|
+
##
|
156
|
+
# Returns boolean indicating whether or not an error was encountered the last time the asset was
|
157
|
+
# processed.
|
158
|
+
#
|
159
|
+
def error?
|
160
|
+
!!@error
|
161
|
+
end
|
162
|
+
|
163
|
+
##
|
164
|
+
# Returns high-level object info string.
|
165
|
+
#
|
166
|
+
def inspect
|
167
|
+
"#<#{self.class}: "\
|
168
|
+
"@errors=#{@errors.inspect}, "\
|
169
|
+
"@extension=#{@extension.inspect}, "\
|
170
|
+
"@file=#{@file.inspect}, "\
|
171
|
+
"@fingerprint=#{@fingerprint.inspect}, "\
|
172
|
+
"@internal=#{@internal.inspect}, "\
|
173
|
+
"@minify=#{@minify.inspect}, "\
|
174
|
+
"@mtime=#{@mtime.inspect}, "\
|
175
|
+
"@path=#{@path.inspect}, "\
|
176
|
+
"@path_versioned=#{@path_versioned.inspect}"\
|
177
|
+
'>'
|
178
|
+
end
|
179
|
+
|
180
|
+
protected
|
181
|
+
|
182
|
+
##
|
183
|
+
# Returns the processed content of the asset without dependencies concatenated.
|
184
|
+
#
|
185
|
+
def own_content
|
186
|
+
@own_content
|
187
|
+
end
|
188
|
+
|
189
|
+
##
|
190
|
+
# Returns an array of all the asset's dependencies.
|
191
|
+
#
|
192
|
+
def dependencies
|
193
|
+
@dependencies
|
194
|
+
end
|
195
|
+
|
196
|
+
private
|
197
|
+
|
198
|
+
##
|
199
|
+
# Clears content, dependencies, and errors so asset is ready for (re)processing.
|
200
|
+
#
|
201
|
+
def clear
|
202
|
+
(@errors ||= []).clear
|
203
|
+
(@dependencies ||= []).clear
|
204
|
+
(@content ||= +'').clear
|
205
|
+
(@own_content ||= +'').clear
|
206
|
+
end
|
207
|
+
|
208
|
+
##
|
209
|
+
# Reads the asset file, building dependency array if dependencies are supported for the asset's type.
|
210
|
+
#
|
211
|
+
# * +key+ - Unique value associated with the current round of processing.
|
212
|
+
#
|
213
|
+
def read(key)
|
214
|
+
if @spec.dependency_regex
|
215
|
+
dependencies = []
|
216
|
+
|
217
|
+
File.new(@file).each.with_index do |line, line_num|
|
218
|
+
if (path = line[@spec.dependency_regex, :path])
|
219
|
+
if (dependency = @manifest[path])
|
220
|
+
dependencies << dependency
|
221
|
+
else
|
222
|
+
@errors << AssetNotFoundError.new(path, @path, line_num + 1)
|
223
|
+
end
|
224
|
+
else
|
225
|
+
@own_content << line
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
dependencies.each do |dependency|
|
230
|
+
dependency.process(key)
|
231
|
+
|
232
|
+
@dependencies += dependency.dependencies
|
233
|
+
@dependencies << dependency
|
234
|
+
end
|
235
|
+
|
236
|
+
@dependencies.uniq!
|
237
|
+
@dependencies.delete_if { |d| d.path == @path }
|
238
|
+
|
239
|
+
@content << @dependencies.map { |d| d.own_content }.join(DEPENDENCY_JOINER)
|
240
|
+
@own_content
|
241
|
+
else
|
242
|
+
@own_content = File.read(@file)
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
##
|
247
|
+
# Compiles the asset if compilation is supported for the asset's type.
|
248
|
+
#
|
249
|
+
def compile
|
250
|
+
if @spec.compile
|
251
|
+
begin
|
252
|
+
@own_content = @spec.compile.call(@path, @own_content)
|
253
|
+
rescue => e
|
254
|
+
@errors << e
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
@content << @own_content
|
259
|
+
end
|
260
|
+
|
261
|
+
##
|
262
|
+
# Minifies the asset if minification is supported for the asset's type, asset is marked as minifiable
|
263
|
+
# (i.e. it's not already minified), and the asset is not marked as internal-only.
|
264
|
+
#
|
265
|
+
def minify
|
266
|
+
if @spec.minify && @minify && !@internal
|
267
|
+
begin
|
268
|
+
@content = @spec.minify.call(@content)
|
269
|
+
rescue => e
|
270
|
+
@errors << e
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
@content
|
275
|
+
end
|
276
|
+
|
277
|
+
##
|
278
|
+
# Requires any libraries necessary for compiling and minifying the asset based on its type. Raises a
|
279
|
+
# MissingLibraryError if library cannot be loaded.
|
280
|
+
#
|
281
|
+
# Darkroom does not explicitly depend on any libraries necessary for asset compilation or minification,
|
282
|
+
# since not every app will use every kind of asset or use minification. It is instead up to each app
|
283
|
+
# using Darkroom to specify any needed compilation and minification libraries as direct dependencies
|
284
|
+
# (e.g. specify +gem('uglifier')+ in the app's Gemfile if JavaScript minification is desired).
|
285
|
+
#
|
286
|
+
def require_libs
|
287
|
+
begin
|
288
|
+
require(@spec.compile_lib) if @spec.compile_lib
|
289
|
+
rescue LoadError
|
290
|
+
raise(MissingLibraryError.new(@spec.compile_lib, 'compile', @extension))
|
291
|
+
end
|
292
|
+
|
293
|
+
begin
|
294
|
+
require(@spec.minify_lib) if @spec.minify_lib && @minify
|
295
|
+
rescue LoadError
|
296
|
+
raise(MissingLibraryError.new(@spec.minify_lib, 'minify', @extension))
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|
@@ -0,0 +1,228 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require('set')
|
4
|
+
|
5
|
+
##
|
6
|
+
# Main class providing fast, lightweight, and straightforward web asset management.
|
7
|
+
#
|
8
|
+
class Darkroom
|
9
|
+
DEFAULT_INTERNAL_PATTERN = nil
|
10
|
+
DEFAULT_MINIFIED_PATTERN = /(\.|-)min\.\w+$/.freeze
|
11
|
+
PRISTINE = Set.new(%w[/favicon.ico /mask-icon.svg /humans.txt /robots.txt]).freeze
|
12
|
+
MIN_PROCESS_INTERVAL = 0.5
|
13
|
+
|
14
|
+
TRAILING_SLASHES = /\/+$/.freeze
|
15
|
+
|
16
|
+
attr_reader(:error, :errors)
|
17
|
+
|
18
|
+
##
|
19
|
+
# Creates a new instance.
|
20
|
+
#
|
21
|
+
# * +load_paths+ - Path(s) where assets are located on disk.
|
22
|
+
# * +host+ - Host(s) to prepend to paths (useful when serving from a CDN in production). If multiple hosts
|
23
|
+
# are specified, they will be round-robined within each thread for each call to +#asset_path+.
|
24
|
+
# * +hosts+ - Alias of +host+ parameter.
|
25
|
+
# * +prefix+ - Prefix to prepend to asset paths (e.g. +/assets+).
|
26
|
+
# * +pristine+ - Path(s) that should not include prefix and for which unversioned form should be provided
|
27
|
+
# by default (e.g. +/favicon.ico+).
|
28
|
+
# * +minify+ - Boolean specifying whether or not to minify assets.
|
29
|
+
# * +minified_pattern+ - Regex used against asset paths to determine if they are already minified and
|
30
|
+
# should therefore be skipped over for minification.
|
31
|
+
# * +internal_pattern+ - Regex used against asset paths to determine if they should be marked as internal
|
32
|
+
# and therefore made inaccessible externally.
|
33
|
+
# * +min_process_interval+ - Minimum time required between one run of asset processing and another.
|
34
|
+
#
|
35
|
+
def initialize(*load_paths, host: nil, hosts: nil, prefix: nil, pristine: nil, minify: false,
|
36
|
+
minified_pattern: DEFAULT_MINIFIED_PATTERN, internal_pattern: DEFAULT_INTERNAL_PATTERN,
|
37
|
+
min_process_interval: MIN_PROCESS_INTERVAL)
|
38
|
+
@globs = load_paths.each_with_object({}) do |path, globs|
|
39
|
+
globs[path.chomp('/')] = File.join(path, '**', "*{#{Asset.extensions.join(',')}}")
|
40
|
+
end
|
41
|
+
|
42
|
+
@hosts = (Array(host) + Array(hosts)).map! { |host| host.sub(TRAILING_SLASHES, '') }
|
43
|
+
@minify = minify
|
44
|
+
@internal_pattern = internal_pattern
|
45
|
+
@minified_pattern = minified_pattern
|
46
|
+
|
47
|
+
@prefix = prefix&.sub(TRAILING_SLASHES, '')
|
48
|
+
@prefix = nil if @prefix && @prefix.empty?
|
49
|
+
|
50
|
+
@pristine = PRISTINE.dup.merge(Array(pristine))
|
51
|
+
|
52
|
+
@min_process_interval = min_process_interval
|
53
|
+
@last_processed_at = 0
|
54
|
+
@mutex = Mutex.new
|
55
|
+
@manifest = {}
|
56
|
+
|
57
|
+
Thread.current[:darkroom_host_index] = -1 unless @hosts.empty?
|
58
|
+
end
|
59
|
+
|
60
|
+
##
|
61
|
+
# Walks all load paths and refreshes any assets that have been modified on disk since the last call to
|
62
|
+
# this method.
|
63
|
+
#
|
64
|
+
def process
|
65
|
+
return if Time.now.to_f - @last_processed_at < @min_process_interval
|
66
|
+
|
67
|
+
if @mutex.locked?
|
68
|
+
@mutex.synchronize {}
|
69
|
+
return
|
70
|
+
end
|
71
|
+
|
72
|
+
@mutex.synchronize do
|
73
|
+
@errors = []
|
74
|
+
found = {}
|
75
|
+
|
76
|
+
@globs.each do |load_path, glob|
|
77
|
+
Dir.glob(glob).sort.each do |file|
|
78
|
+
path = file.sub(load_path, '')
|
79
|
+
|
80
|
+
if found.key?(path)
|
81
|
+
@errors << DuplicateAssetError.new(path, found[path], load_path)
|
82
|
+
else
|
83
|
+
found[path] = load_path
|
84
|
+
|
85
|
+
@manifest[path] ||= Asset.new(path, file, @manifest,
|
86
|
+
internal: internal = @internal_pattern && path =~ @internal_pattern,
|
87
|
+
minify: @minify && !internal && path !~ @minified_pattern,
|
88
|
+
)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
@manifest.select! { |path, _| found.key?(path) }
|
94
|
+
|
95
|
+
found.each do |path, _|
|
96
|
+
@manifest[path].process(@last_processed_at)
|
97
|
+
@manifest[@manifest[path].path_versioned] = @manifest[path]
|
98
|
+
|
99
|
+
@errors += @manifest[path].errors
|
100
|
+
end
|
101
|
+
ensure
|
102
|
+
@last_processed_at = Time.now.to_f
|
103
|
+
@error = @errors.empty? ? nil : ProcessingError.new(@errors)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
##
|
108
|
+
# Does the same thing as #process, but raises an exception if any errors were encountered.
|
109
|
+
#
|
110
|
+
def process!
|
111
|
+
process
|
112
|
+
|
113
|
+
raise(@error) if @error
|
114
|
+
end
|
115
|
+
|
116
|
+
##
|
117
|
+
# Returns boolean indicating whether or not there were any errors encountered the last time assets were
|
118
|
+
# processed.
|
119
|
+
#
|
120
|
+
def error?
|
121
|
+
!!@error
|
122
|
+
end
|
123
|
+
|
124
|
+
##
|
125
|
+
# Returns an Asset object, given its external path. An external path includes any prefix and and can be
|
126
|
+
# either the versioned or unversioned form of the asset path (i.e. how an HTTP request for the asset comes
|
127
|
+
# in). For example, to get the Asset object with path +/js/app.js+ when prefix is +/assets+:
|
128
|
+
#
|
129
|
+
# darkroom.asset('/assets/js/app.<hash>.js')
|
130
|
+
# darkroom.asset('/assets/js/app.js')
|
131
|
+
#
|
132
|
+
# * +path+ - The external path of the asset.
|
133
|
+
#
|
134
|
+
def asset(path)
|
135
|
+
asset = @manifest[@prefix ? path.sub(@prefix, '') : path]
|
136
|
+
|
137
|
+
return nil if asset.nil?
|
138
|
+
return nil if asset.internal?
|
139
|
+
return nil if @prefix && !path.start_with?(@prefix) && !@pristine.include?(asset.path)
|
140
|
+
return nil if @prefix && path.start_with?(@prefix) && @pristine.include?(asset.path)
|
141
|
+
|
142
|
+
asset
|
143
|
+
end
|
144
|
+
|
145
|
+
##
|
146
|
+
# Returns the external asset path, given its internal path. An external path includes any prefix and and
|
147
|
+
# can be either the versioned or unversioned form of the asset path (i.e. how an HTTP request for the
|
148
|
+
# asset comes in). For example, to get the external path for the Asset object with path +/js/app.js+ when
|
149
|
+
# prefix is +/assets+:
|
150
|
+
#
|
151
|
+
# darkroom.asset_path('/js/app.js') # => /assets/js/app.<hash>.js
|
152
|
+
# darkroom.asset_path('/js/app.js', versioned: false) # => /assets/js/app.js
|
153
|
+
#
|
154
|
+
# * +path+ - The internal path of the asset.
|
155
|
+
# * +versioned+ - Boolean indicating whether the versioned or unversioned path should be returned.
|
156
|
+
#
|
157
|
+
def asset_path(path, versioned: !@pristine.include?(path))
|
158
|
+
asset = @manifest[path] or return nil
|
159
|
+
|
160
|
+
host = @hosts.empty? ? '' : @hosts[
|
161
|
+
Thread.current[:darkroom_host_index] = (Thread.current[:darkroom_host_index] + 1) % @hosts.size
|
162
|
+
]
|
163
|
+
prefix = @prefix unless @pristine.include?(path)
|
164
|
+
|
165
|
+
"#{host}#{prefix}#{versioned ? asset.path_versioned : path}"
|
166
|
+
end
|
167
|
+
|
168
|
+
##
|
169
|
+
# Calls #asset_path and raises a AssetNotFoundError if the asset doesn't exist (instead of just returning
|
170
|
+
# nil).
|
171
|
+
#
|
172
|
+
# * +versioned+ - Boolean indicating whether the versioned or unversioned path should be returned.
|
173
|
+
#
|
174
|
+
def asset_path!(path, versioned: true)
|
175
|
+
asset_path(path, versioned: versioned) or raise(AssetNotFoundError.new(path))
|
176
|
+
end
|
177
|
+
|
178
|
+
##
|
179
|
+
# Writes assets to disk. This is useful when deploying to a production environment where assets will be
|
180
|
+
# uploaded to and served from a CDN or proxy server.
|
181
|
+
#
|
182
|
+
# * +dir+ - Directory to write the assets to.
|
183
|
+
# * +clear+ - Boolean indicating whether or not the existing contents of the directory should be deleted
|
184
|
+
# before performing the dump.
|
185
|
+
# * +include_pristine+ - Boolean indicating whether or not to include pristine assets (when dumping for
|
186
|
+
# the purpose of uploading to a CDN, assets such as /robots.txt and /favicon.ico don't need to be
|
187
|
+
# included).
|
188
|
+
#
|
189
|
+
def dump(dir, clear: false, include_pristine: true)
|
190
|
+
dir = File.expand_path(dir)
|
191
|
+
written = Set.new
|
192
|
+
|
193
|
+
FileUtils.mkdir_p(dir)
|
194
|
+
Dir.each_child(dir) { |child| FileUtils.rm_rf(File.join(dir, child)) } if clear
|
195
|
+
|
196
|
+
@manifest.each do |_, asset|
|
197
|
+
next if asset.internal?
|
198
|
+
next if written.include?(asset.path)
|
199
|
+
next if @pristine.include?(asset.path) && !include_pristine
|
200
|
+
|
201
|
+
external_path = asset_path(asset.path)
|
202
|
+
file_path = File.join(dir, external_path)
|
203
|
+
|
204
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
205
|
+
File.write(file_path, asset.content)
|
206
|
+
|
207
|
+
written << asset.path
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
##
|
212
|
+
# Returns high-level object info string.
|
213
|
+
#
|
214
|
+
def inspect
|
215
|
+
"#<#{self.class}: "\
|
216
|
+
"@errors=#{@errors.inspect}, "\
|
217
|
+
"@globs=#{@globs.inspect}, "\
|
218
|
+
"@hosts=#{@hosts.inspect}, "\
|
219
|
+
"@internal_pattern=#{@internal_pattern.inspect}, "\
|
220
|
+
"@last_processed_at=#{@last_processed_at.inspect}, "\
|
221
|
+
"@min_process_interval=#{@min_process_interval.inspect}, "\
|
222
|
+
"@minified_pattern=#{@minified_pattern.inspect}, "\
|
223
|
+
"@minify=#{@minify.inspect}, "\
|
224
|
+
"@prefix=#{@prefix.inspect}, "\
|
225
|
+
"@pristine=#{@pristine.inspect}"\
|
226
|
+
'>'
|
227
|
+
end
|
228
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Darkroom
|
4
|
+
##
|
5
|
+
# Error class used when an asset requested explicitly or specified as a dependency of another cannot be
|
6
|
+
# found.
|
7
|
+
#
|
8
|
+
class AssetNotFoundError < StandardError
|
9
|
+
attr_reader(:path, :referenced_from, :referenced_from_line)
|
10
|
+
|
11
|
+
##
|
12
|
+
# Creates a new instance.
|
13
|
+
#
|
14
|
+
# * +path+ - The path of the asset that cannot be found.
|
15
|
+
# * +referenced_from+ - The path of the asset the not-found asset was referenced from.
|
16
|
+
# * +referenced_from_line+ - The line number where the not-found asset was referenced.
|
17
|
+
#
|
18
|
+
def initialize(path, referenced_from = nil, referenced_from_line = nil)
|
19
|
+
@path = path
|
20
|
+
@referenced_from = referenced_from
|
21
|
+
@referenced_from_line = referenced_from_line
|
22
|
+
end
|
23
|
+
|
24
|
+
##
|
25
|
+
# Returns a string representation of the error.
|
26
|
+
#
|
27
|
+
def to_s
|
28
|
+
"Asset not found#{
|
29
|
+
" (referenced from #{@referenced_from}:#{@referenced_from_line || '?'})" if @referenced_from
|
30
|
+
}: #{@path}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Darkroom
|
4
|
+
##
|
5
|
+
# Error class used when an asset exists in multiple load paths.
|
6
|
+
#
|
7
|
+
class DuplicateAssetError < StandardError
|
8
|
+
attr_reader(:path, :first_load_path, :second_load_path)
|
9
|
+
|
10
|
+
##
|
11
|
+
# Creates a new instance.
|
12
|
+
#
|
13
|
+
# * +path+ - The path of the asset that has the same path as another asset.
|
14
|
+
# * +first_load_path+ - The load path where the first asset with the path was found.
|
15
|
+
# * +second_load_path+ - The load path where the second asset with the path was found.
|
16
|
+
#
|
17
|
+
def initialize(path, first_load_path, second_load_path)
|
18
|
+
@path = path
|
19
|
+
@first_load_path = first_load_path
|
20
|
+
@second_load_path = second_load_path
|
21
|
+
end
|
22
|
+
|
23
|
+
##
|
24
|
+
# Returns a string representation of the error.
|
25
|
+
#
|
26
|
+
def to_s
|
27
|
+
"Asset file exists in both #{@first_load_path} and #{@second_load_path}: #{@path}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Darkroom
|
4
|
+
##
|
5
|
+
# Error class used when a needed library cannot be loaded. See Asset#require_libs.
|
6
|
+
#
|
7
|
+
class MissingLibraryError < StandardError
|
8
|
+
attr_reader(:library, :need, :extension)
|
9
|
+
|
10
|
+
##
|
11
|
+
# Creates a new instance.
|
12
|
+
#
|
13
|
+
# * +library+ - The name of the library that's missing.
|
14
|
+
# * +need+ - The reason the library is needed ('compile' or 'minify').
|
15
|
+
# * +extension+ - The extenion of the type of asset that needs the library.
|
16
|
+
#
|
17
|
+
def initialize(library, need, extension)
|
18
|
+
@library = library
|
19
|
+
@need = need
|
20
|
+
@extension = extension
|
21
|
+
end
|
22
|
+
|
23
|
+
##
|
24
|
+
# Returns a string representation of the error.
|
25
|
+
#
|
26
|
+
def to_s
|
27
|
+
"Cannot #{@need} #{@extension} file(s): #{@library} library not available"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Darkroom
|
4
|
+
##
|
5
|
+
# Error class used to wrap all accumulated errors encountered during asset processing.
|
6
|
+
#
|
7
|
+
class ProcessingError < StandardError
|
8
|
+
##
|
9
|
+
# Creates a new instance.
|
10
|
+
#
|
11
|
+
# * +errors+ - Error or array of errors.
|
12
|
+
#
|
13
|
+
def initialize(errors)
|
14
|
+
@errors = Array(errors)
|
15
|
+
end
|
16
|
+
|
17
|
+
##
|
18
|
+
# Returns a string representation of the error.
|
19
|
+
#
|
20
|
+
def to_s
|
21
|
+
"Errors were encountered while processing assets:\n #{@errors.map(&:to_s).join("\n ")}"
|
22
|
+
end
|
23
|
+
|
24
|
+
##
|
25
|
+
# Passes any missing method call on to the @errors array.
|
26
|
+
#
|
27
|
+
def method_missing(m, *args, &block)
|
28
|
+
@errors.send(m, *args, &block)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Darkroom
|
4
|
+
##
|
5
|
+
# Error class used when a spec is not defined for a particular file extension.
|
6
|
+
#
|
7
|
+
class SpecNotDefinedError < StandardError
|
8
|
+
attr_reader(:extension, :file)
|
9
|
+
|
10
|
+
##
|
11
|
+
# Creates a new instance.
|
12
|
+
#
|
13
|
+
# * +extension+ - Extension for which there is no spec defined.
|
14
|
+
# * +file+ - File path of the asset whose loading was attempted.
|
15
|
+
#
|
16
|
+
def initialize(extension, file = nil)
|
17
|
+
@extension = extension
|
18
|
+
@file = file
|
19
|
+
end
|
20
|
+
|
21
|
+
##
|
22
|
+
# Returns a string representation of the error.
|
23
|
+
#
|
24
|
+
def to_s
|
25
|
+
"Spec not defined for #{@extension} files#{" (#{@file})" if @file}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
metadata
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: darkroom
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Nate Pickens
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-02-25 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '2.0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '2.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: minitest
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 5.11.2
|
34
|
+
- - "<"
|
35
|
+
- !ruby/object:Gem::Version
|
36
|
+
version: 6.0.0
|
37
|
+
type: :development
|
38
|
+
prerelease: false
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: 5.11.2
|
44
|
+
- - "<"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: 6.0.0
|
47
|
+
description: Darkroom provides simple web asset management complete with dependency
|
48
|
+
bundling based on import statements, compilation, and minification.
|
49
|
+
email:
|
50
|
+
executables: []
|
51
|
+
extensions: []
|
52
|
+
extra_rdoc_files: []
|
53
|
+
files:
|
54
|
+
- LICENSE
|
55
|
+
- README.md
|
56
|
+
- VERSION
|
57
|
+
- lib/darkroom.rb
|
58
|
+
- lib/darkroom/asset.rb
|
59
|
+
- lib/darkroom/darkroom.rb
|
60
|
+
- lib/darkroom/errors/asset_not_found_error.rb
|
61
|
+
- lib/darkroom/errors/duplicate_asset_error.rb
|
62
|
+
- lib/darkroom/errors/missing_library_error.rb
|
63
|
+
- lib/darkroom/errors/processing_error.rb
|
64
|
+
- lib/darkroom/errors/spec_not_defined_error.rb
|
65
|
+
- lib/darkroom/version.rb
|
66
|
+
homepage: https://github.com/npickens/darkroom
|
67
|
+
licenses:
|
68
|
+
- MIT
|
69
|
+
metadata:
|
70
|
+
allowed_push_host: https://rubygems.org
|
71
|
+
homepage_uri: https://github.com/npickens/darkroom
|
72
|
+
source_code_uri: https://github.com/npickens/darkroom
|
73
|
+
post_install_message:
|
74
|
+
rdoc_options: []
|
75
|
+
require_paths:
|
76
|
+
- lib
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: 2.5.8
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
83
|
+
requirements:
|
84
|
+
- - ">="
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: '0'
|
87
|
+
requirements: []
|
88
|
+
rubygems_version: 3.2.3
|
89
|
+
signing_key:
|
90
|
+
specification_version: 4
|
91
|
+
summary: A fast, lightweight, and straightforward web asset management library.
|
92
|
+
test_files: []
|