darkroom 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 +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: []
|