Class: Darkroom

Inherits:
Object
  • Object
show all
Defined in:
lib/darkroom/darkroom.rb,
lib/darkroom/asset.rb,
lib/darkroom/version.rb,
lib/darkroom/delegate.rb,
lib/darkroom/errors/asset_error.rb,
lib/darkroom/delegates/css_delegate.rb,
lib/darkroom/delegates/htx_delegate.rb,
lib/darkroom/delegates/html_delegate.rb,
lib/darkroom/errors/processing_error.rb,
lib/darkroom/errors/invalid_path_error.rb,
lib/darkroom/errors/asset_not_found_error.rb,
lib/darkroom/errors/duplicate_asset_error.rb,
lib/darkroom/errors/missing_library_error.rb,
lib/darkroom/delegates/javascript_delegate.rb,
lib/darkroom/errors/circular_reference_error.rb,
lib/darkroom/errors/unrecognized_extension_error.rb

Overview

Main class providing simple and straightforward web asset management.

Defined Under Namespace

Classes: Asset, AssetError, AssetNotFoundError, CSSDelegate, CircularReferenceError, Delegate, DuplicateAssetError, HTMLDelegate, HTXDelegate, InvalidPathError, JavaScriptDelegate, MissingLibraryError, ProcessingError, UnrecognizedExtensionError

Constant Summary collapse

VERSION =
'0.0.10'
DEFAULT_MINIFIED =
/(\.|-)min\.\w+$/.freeze
TRAILING_SLASHES =
%r{/+$}.freeze
PRISTINE =
Set.new(%w[/favicon.ico /mask-icon.svg /humans.txt /robots.txt]).freeze
MIN_PROCESS_INTERVAL =
0.5
@@delegates =
{}
@@glob =
''

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(*load_paths, host: nil, hosts: nil, prefix: nil, pristine: nil, entries: nil, minify: false, minified: DEFAULT_MINIFIED, min_process_interval: MIN_PROCESS_INTERVAL) ⇒ Darkroom

Public: Create a new instance.

load_paths - One or more String paths where assets are located on disk. host: - String host or Array of String hosts to prepend to paths (useful when serving

from a CDN in production). If multiple hosts are specified, they will be round-
robined within each thread for each call to #asset_path.

hosts: - String or Array of Strings (alias of host:). prefix: - String prefix to prepend to asset paths (e.g. ‘/assets’). pristine: - String, Array of String, or Set of String paths that should not include the

prefix and for which the unversioned form should be provided by default (e.g.
'/favicon.ico').

entries: - String, Regexp, or Array of String and/or Regexp specifying entry point paths /

path patterns.

minify: - Boolean specifying if assets that support it should be minified. minified: - String, Regexp, or Array of String and/or Regexp specifying paths of assets that

are already minified and thus shouldn't be minified.

min_process_interval: - Numeric minimum number of seconds required between one run of asset processing

and another.


94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/darkroom/darkroom.rb', line 94

def initialize(*load_paths, host: nil, hosts: nil, prefix: nil, pristine: nil, entries: nil,
               minify: false, minified: DEFAULT_MINIFIED, min_process_interval: MIN_PROCESS_INTERVAL)
  @load_paths = load_paths.map { |load_path| File.expand_path(load_path) }

  @hosts = (Array(host) + Array(hosts)).map! { |h| h.sub(TRAILING_SLASHES, '') }
  @entries = Array(entries)
  @minify = minify
  @minified = Array(minified)

  @prefix = prefix&.sub(TRAILING_SLASHES, '')
  @prefix = nil if @prefix && @prefix.empty?

  @pristine = PRISTINE.dup.merge(Array(pristine))

  @min_process_interval = min_process_interval
  @last_processed_at = 0
  @process_key = 0
  @mutex = Mutex.new

  @manifest = {}
  @manifest_unversioned = {}
  @manifest_versioned = {}

  @errors = []

  Thread.current[:darkroom_host_index] = -1 unless @hosts.empty?
end

Class Attribute Details

.javascript_iifeObject

Returns the value of attribute javascript_iife.



24
25
26
# File 'lib/darkroom/darkroom.rb', line 24

def javascript_iife
  @javascript_iife
end

Instance Attribute Details

#errorObject (readonly)

Returns the value of attribute error.



22
23
24
# File 'lib/darkroom/darkroom.rb', line 22

def error
  @error
end

#errorsObject (readonly)

Returns the value of attribute errors.



22
23
24
# File 'lib/darkroom/darkroom.rb', line 22

def errors
  @errors
end

#process_keyObject (readonly)

Returns the value of attribute process_key.



22
23
24
# File 'lib/darkroom/darkroom.rb', line 22

def process_key
  @process_key
end

Class Method Details

.delegate(extension) ⇒ Object

Public: Get the Delegate associated with a file extension.

extension - String file extension of the desired delegate (e.g. ‘.js’)

Returns the Delegate class.



72
73
74
# File 'lib/darkroom/darkroom.rb', line 72

def self.delegate(extension)
  @@delegates[extension]
end

.register(*args, &block) ⇒ Object

Public: Register a delegate for handling a specific kind of asset.

args - One or more String file extensions to associate with this delegate, optionally followed by

either an HTTP MIME type String or a Delegate subclass.

block - Block to call that defines or extends the Delegate.

Examples

Darkroom.register('.ext1', '.ext2', 'content/type')
Darkroom.register('.ext', MyDelegateSubclass)

Darkroom.register('.scss', 'text/css') do
  compile(lib: 'sassc') { ... }
end

Darkroom.register('.scss', SCSSDelegate) do
  # Modifications/overrides of the SCSSDelegate class...
end

Returns the Delegate class.



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/darkroom/darkroom.rb', line 46

def self.register(*args, &block)
  last_arg = args.pop unless args.last.kind_of?(String) && args.last[0] == '.'
  extensions = args

  if last_arg.nil? || last_arg.kind_of?(String)
    content_type = last_arg
    delegate = Class.new(Delegate, &block)
    delegate.content_type(content_type) if content_type && !delegate.content_type
  elsif last_arg.kind_of?(Class) && last_arg < Delegate
    delegate = block ? Class.new(last_arg, &block) : last_arg
  end

  extensions.each do |extension|
    @@delegates[extension] = delegate
  end

  @@glob = "**/*{#{@@delegates.keys.sort.join(',')}}"

  delegate
end

Instance Method Details

#asset(path) ⇒ Object

Public: Get an Asset object, given its external path. An external path includes any prefix and can be either the versioned or unversioned form (i.e. how an HTTP request for the asset comes in).

Examples

# Suppose the asset's internal path is '/js/app.js' and the prefix is '/assets'.
darkroom.asset('/assets/js/app-<hash>.js') # => #<Darkroom::Asset [...]>
darkroom.asset('/assets/js/app.js')        # => #<Darkroom::Asset [...]>

path - String external path of the asset.

Returns the Asset object if it exists or nil otherwise.



224
225
226
# File 'lib/darkroom/darkroom.rb', line 224

def asset(path)
  @manifest_versioned[path] || @manifest_unversioned[path]
end

#asset_integrity(path, algorithm = nil) ⇒ Object

Public: Get an asset’s subresource integrity string.

path - String internal path of the asset. algorithm - Symbol hash algorithm name to use to generate the integrity string (must be one of

:sha256, :sha384, :sha512).

Returns the asset’s subresource integrity String. Raises AssetNotFoundError if the asset doesn’t exist.



263
264
265
266
267
# File 'lib/darkroom/darkroom.rb', line 263

def asset_integrity(path, algorithm = nil)
  asset = @manifest[path] or raise(AssetNotFoundError.new(path))

  algorithm ? asset.integrity(algorithm) : asset.integrity
end

#asset_path(path, versioned: !@pristine.include?(path))) ⇒ Object

Public: Get the external asset path, given its internal path. An external path includes any prefix and can be either the versioned or unversioned form (i.e. how an HTTP request for the asset comes in).

path - String internal path of the asset. versioned: - Boolean specifying either the versioned or unversioned path to be returned.

Examples

# Suppose the asset's internal path is '/js/app.js' and the prefix is '/assets'.
darkroom.asset_path('/js/app.js')                   # => "/assets/js/app-<hash>.js"
darkroom.asset_path('/js/app.js', versioned: false) # => "/assets/js/app.js"

Returns the String external asset path. Raises AssetNotFoundError if the asset doesn’t exist.



242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/darkroom/darkroom.rb', line 242

def asset_path(path, versioned: !@pristine.include?(path))
  asset = @manifest[path] or raise(AssetNotFoundError.new(path))

  unless @hosts.empty?
    host_index = (Thread.current[:darkroom_host_index] + 1) % @hosts.size
    host = @hosts[host_index]

    Thread.current[:darkroom_host_index] = host_index
  end

  "#{host}#{versioned ? asset.path_versioned : asset.path_unversioned}"
end

#dump(dir, clear: false, include_pristine: true) ⇒ Object

Public: Write assets to disk. This is useful when deploying to a production environment where assets will be uploaded to and served from a CDN or proxy server. Note that #process must be called manually before calling this method.

dir - String directory path to write the assets to. clear: - Boolean indicating if the existing contents of the directory should be deleted

before writing files.

include_pristine: - Boolean indicating if pristine assets should be included (when dumping for the

purpose of uploading to a CDN, assets such as /robots.txt and /favicon.ico don't
need to be included).

Returns nothing. Raises ProcessingError if errors were encountered during the last #process run.

Raises:



291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/darkroom/darkroom.rb', line 291

def dump(dir, clear: false, include_pristine: true)
  raise(@error) if @error

  require('fileutils')

  dir = File.expand_path(dir)

  FileUtils.mkdir_p(dir)
  Dir.each_child(dir) { |child| FileUtils.rm_rf(File.join(dir, child)) } if clear

  @manifest_versioned.each do |path, asset|
    next if @pristine.include?(asset.path) && !include_pristine

    file_path = File.join(dir, @pristine.include?(asset.path) ? asset.path_unversioned : path)

    FileUtils.mkdir_p(File.dirname(file_path))
    File.write(file_path, asset.content)
  end
end

#error?Boolean

Public: Check if there were any errors encountered the last time assets were processed.

Returns the boolean result.

Returns:

  • (Boolean)


208
209
210
# File 'lib/darkroom/darkroom.rb', line 208

def error?
  !!@error
end

#inspectObject

Public: Get a high-level object info string about this Darkroom instance.

Returns the String.



314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/darkroom/darkroom.rb', line 314

def inspect
  "#<#{self.class} " \
    "@entries=#{@entries.inspect}, " \
    "@errors=#{@errors.inspect}, " \
    "@hosts=#{@hosts.inspect}, " \
    "@last_processed_at=#{@last_processed_at.inspect}, " \
    "@load_paths=#{@load_paths.inspect}, " \
    "@min_process_interval=#{@min_process_interval.inspect}, " \
    "@minified=#{@minified.inspect}, " \
    "@minify=#{@minify.inspect}, " \
    "@prefix=#{@prefix.inspect}, " \
    "@pristine=#{@pristine.inspect}, " \
    "@process_key=#{@process_key.inspect}" \
  '>'
end

#manifest(path) ⇒ Object

Public: Get the Asset object from the manifest Hash associated with the given path.

path - String internal path of the asset.

Returns the Asset object if it exists or nil otherwise.



274
275
276
# File 'lib/darkroom/darkroom.rb', line 274

def manifest(path)
  @manifest[path]
end

#processObject

Public: Walk all load paths and refresh any assets that have been modified on disk since the last call to this method. Processing is skipped if either a) a previous call to this method happened less than min_process_interval seconds ago or b) another thread is currently executing this method.

A mutex is used to ensure that, say, multiple web request threads do not trample each other. If the mutex is locked when this method is called, it will wait until the mutex is released to ensure that the caller does not then start working with stale / invalid Asset objects due to the work of the other thread’s active call to #process being incomplete.

If any errors are encountered during processing, they must be checked for manually afterward via #error or #errors. If a raise is preferred, use #process! instead.

Returns boolean indicating if processing actually happened (true) or was skipped (false).



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/darkroom/darkroom.rb', line 135

def process
  return false if Time.now.to_f - @last_processed_at < @min_process_interval

  if @mutex.locked?
    @mutex.synchronize {} # Wait until other #process call is done to avoid stale/invalid assets.
    return false
  end

  @mutex.synchronize do
    @process_key += 1
    @errors.clear
    found = {}

    @load_paths.each do |load_path|
      Dir.glob(File.join(load_path, @@glob)).each do |file|
        path = file.sub(load_path, '')

        if (index = path.index(Asset::INVALID_PATH_REGEX))
          @errors << InvalidPathError.new(path, index)
        elsif found.key?(path)
          @errors << DuplicateAssetError.new(path, found[path], load_path)
        else
          found[path] = load_path

          unless @manifest.key?(path)
            entry = entry?(path)

            @manifest[path] = Asset.new(
              path, file, self,
              prefix: (@prefix unless @pristine.include?(path)),
              entry: entry,
              minify: entry && @minify && !minified?(path),
            )
          end
        end
      end
    end

    @manifest.select! { |path, _| found.key?(path) }
    @manifest_unversioned.clear
    @manifest_versioned.clear

    @manifest.each_value do |asset|
      asset.process

      if asset.entry?
        @manifest_unversioned[asset.path_unversioned] = asset
        @manifest_versioned[asset.path_versioned] = asset
      end

      @errors.concat(asset.errors)
    end

    true
  ensure
    @last_processed_at = Time.now.to_f
    @error = @errors.empty? ? nil : ProcessingError.new(@errors)
  end
end

#process!Object

Public: Call #process but raise an error if there were errors.

Returns boolean indicating if processing actually happened (true) or was skipped (false). Raises ProcessingError if processing actually happened from this call and error(s) were encountered.



199
200
201
202
203
# File 'lib/darkroom/darkroom.rb', line 199

def process!
  result = process

  result && @error ? raise(@error) : result
end