darkroom 0.0.6 → 0.0.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,7 +4,6 @@ require('base64')
4
4
  require('digest')
5
5
  require('set')
6
6
 
7
- require_relative('darkroom')
8
7
  require_relative('errors/asset_error')
9
8
  require_relative('errors/asset_not_found_error')
10
9
  require_relative('errors/circular_reference_error')
@@ -18,18 +17,16 @@ class Darkroom
18
17
  #
19
18
  class Asset
20
19
  EXTENSION_REGEX = /(?=\.\w+)/.freeze
21
-
22
- IMPORT_JOINER = "\n"
23
20
  DEFAULT_QUOTE = '\''
24
-
25
- QUOTED_PATH = /(?<quote>['"])(?<path>[^'"]*)\k<quote>/.freeze
26
- REFERENCE_PATH =
27
- %r{
28
- (?<quote>['"]?)(?<quoted>
29
- (?<path>[^#{DISALLOWED_PATH_CHARS}]+)
30
- \?asset-(?<entity>path|content)(=(?<format>\w*))?
31
- )\k<quote>
32
- }x.freeze
21
+ DISALLOWED_PATH_CHARS = '\'"`=<>? '
22
+ INVALID_PATH_REGEX = /[#{DISALLOWED_PATH_CHARS}]/.freeze
23
+ PATH_REGEX = /(?<path>[^#{DISALLOWED_PATH_CHARS}]*)/.freeze
24
+ QUOTED_PATH_REGEX = /(?<quote>['"])#{PATH_REGEX.source}\k<quote>/.freeze
25
+ REFERENCE_REGEX = /
26
+ (?<quote>['"]?)
27
+ (?<quoted>#{PATH_REGEX.source}\?asset-(?<entity>path|content)(=(?<format>\w*))?)
28
+ \k<quote>
29
+ /x.freeze
33
30
 
34
31
  # First item of each set is used as default, so order is important.
35
32
  REFERENCE_FORMATS = {
@@ -37,94 +34,60 @@ class Darkroom
37
34
  'content' => Set.new(%w[base64 utf8 displace]),
38
35
  }.freeze
39
36
 
40
- attr_reader(:content, :error, :errors, :path, :path_unversioned, :path_versioned)
41
-
42
- ##
43
- # Holds information about how to handle a particular asset type.
44
- #
45
- # * +content_type+ - HTTP MIME type string.
46
- # * +import_regex+ - Regex to find import statements. Must contain a named component called 'path'
47
- # (e.g. +/^import (?<path>.*)/+).
48
- # * +reference_regex+ - Regex to find references to other assets. Must contain three named components:
49
- # * +path+ - Path of the asset being referenced.
50
- # * +entity+ - Desired entity (path or content).
51
- # * +format+ - Format to use (see REFERENCE_FORMATS).
52
- # * +validate_reference+ - Lambda to call to validate a reference. Should return nil if there are no
53
- # errors and a string error message if validation fails. Three arguments are passed when called:
54
- # * +asset+ - Asset object of the asset being referenced.
55
- # * +match+ - MatchData object from the match against +reference_regex+.
56
- # * +format+ - Format of the reference (see REFERENCE_FORMATS).
57
- # * +reference_content+ - Lambda to call to get the content for a reference. Should return nil if the
58
- # default behavior is desired or a string for custom content. Three arguments are passed when called:
59
- # * +asset+ - Asset object of the asset being referenced.
60
- # * +match+ - MatchData object from the match against +reference_regex+.
61
- # * +format+ - Format of the reference (see REFERENCE_FORMATS).
62
- # * +compile_lib+ - Name of a library to +require+ that is needed by the +compile+ lambda.
63
- # * +compile+ - Lambda to call that will return the compiled version of the asset's content. Two
64
- # arguments are passed when called:
65
- # * +path+ - Path of the asset being compiled.
66
- # * +content+ - Content to compile.
67
- # * +minify_lib+ - Name of a library to +require+ that is needed by the +minify+ lambda.
68
- # * +minify+ - Lambda to call that will return the minified version of the asset's content. One argument
69
- # is passed when called:
70
- # * +content+ - Content to minify.
71
- #
72
- Delegate = Struct.new(:content_type, :import_regex, :reference_regex, :validate_reference,
73
- :reference_content, :compile_lib, :compile, :minify_lib, :minify, keyword_init: true)
37
+ attr_reader(:errors, :path, :path_unversioned)
74
38
 
75
39
  ##
76
40
  # Creates a new instance.
77
41
  #
78
- # * +file+ - Path of file on disk.
79
- # * +path+ - Path this asset will be referenced by (e.g. /js/app.js).
80
- # * +darkroom+ - Darkroom instance that the asset is a member of.
81
- # * +prefix+ - Prefix to apply to unversioned and versioned paths.
82
- # * +minify+ - Boolean specifying whether or not the asset should be minified when processed.
83
- # * +internal+ - Boolean indicating whether or not the asset is only accessible internally (i.e. as an
84
- # import or reference).
42
+ # [file] Path of file on disk.
43
+ # [path] Path this asset will be referenced by (e.g. /js/app.js).
44
+ # [darkroom] Darkroom instance that the asset is a member of.
45
+ # [prefix:] Prefix to apply to unversioned and versioned paths.
46
+ # [entry:] Boolean indicating whether or not the asset is an entry point (i.e. accessible externally).
47
+ # [minify:] Boolean specifying whether or not the asset should be minified when processed.
48
+ # [intermediate:] Boolean indicating whether or not the asset exists solely to provide an intermediate
49
+ # form (e.g. compiled) to another asset instance.
85
50
  #
86
- def initialize(path, file, darkroom, prefix: nil, minify: false, internal: false)
51
+ def initialize(path, file, darkroom, prefix: nil, entry: true, minify: false, intermediate: false)
87
52
  @path = path
53
+ @dir = File.dirname(path)
88
54
  @file = file
89
55
  @darkroom = darkroom
90
56
  @prefix = prefix
57
+ @entry = entry
91
58
  @minify = minify
92
- @internal = internal
93
59
 
94
60
  @path_unversioned = "#{@prefix}#{@path}"
95
61
  @extension = File.extname(@path).downcase
96
62
  @delegate = Darkroom.delegate(@extension) or raise(UnrecognizedExtensionError.new(@path))
97
63
 
64
+ @ran = Set.new
65
+
66
+ if @delegate.compile_delegate && !intermediate
67
+ @delegate = @delegate.compile_delegate
68
+ @intermediate_asset = Asset.new(@path, @file, @darkroom,
69
+ prefix: @prefix,
70
+ entry: false,
71
+ minify: false,
72
+ intermediate: true,
73
+ )
74
+ end
75
+
98
76
  require_libs
99
77
  clear
100
78
  end
101
79
 
102
80
  ##
103
- # Processes the asset if modified (see #modified? for how modification is determined). File is read from
104
- # disk, references are substituted (if supported), content is compiled (if required), imports are
105
- # prefixed to its content (if supported), and content is minified (if supported and enabled). Returns
106
- # true if asset was modified since it was last processed and false otherwise.
81
+ # Processes the asset if modified since the last run (see #modified? for how modification is
82
+ # determined). File is read from disk, references are substituted (if supported), content is compiled
83
+ # (if required), imports are prefixed to its content (if supported), and content is minified
84
+ # (if supported and enabled and the asset is an entry point).
107
85
  #
108
86
  def process
109
- @process_key == @darkroom.process_key ? (return @processed) : (@process_key = @darkroom.process_key)
110
- modified? ? (@processed = true) : (return @processed = false)
87
+ return if ran?(:process)
111
88
 
112
- clear
113
- read
114
- build_imports
115
- build_references
116
- process_dependencies
117
89
  compile
118
- minify
119
-
120
- @fingerprint = Digest::MD5.hexdigest(@content)
121
- @path_versioned = "#{@prefix}#{@path.sub(EXTENSION_REGEX, "-#{@fingerprint}")}"
122
-
123
- @processed
124
- rescue Errno::ENOENT
125
- # File was deleted. Do nothing.
126
- ensure
127
- @error = @errors.empty? ? nil : ProcessingError.new(@errors)
90
+ content if entry?
128
91
  end
129
92
 
130
93
  ##
@@ -159,49 +122,139 @@ class Darkroom
159
122
  defined?(@is_image) ? @is_image : (@is_image = content_type.start_with?('image/'))
160
123
  end
161
124
 
125
+ ##
126
+ # Returns boolean indicating whether or not the asset is an entry point.
127
+ #
128
+ def entry?
129
+ @entry
130
+ end
131
+
132
+ ##
133
+ # DEPRECATED: use #entry? instead. Returns boolean indicating whether or not the asset is marked as
134
+ # internal.
135
+ #
136
+ def internal?
137
+ Darkroom.deprecated("#{self.class.name}#internal? is deprecated: use #entry? instead")
138
+
139
+ !entry?
140
+ end
141
+
142
+ ##
143
+ # Returns boolean indicating whether or not an error was encountered the last time the asset was
144
+ # processed.
145
+ #
146
+ def error?
147
+ !@errors.empty?
148
+ end
149
+
150
+ ##
151
+ # Returns ProcessingError wrapper of all errors if any exist, or nil if there are none.
152
+ #
153
+ def error
154
+ @error ||= @errors.empty? ? nil : ProcessingError.new(@errors)
155
+ end
156
+
157
+ ##
158
+ # Returns hash of content.
159
+ #
160
+ def fingerprint
161
+ content
162
+
163
+ @fingerprint
164
+ end
165
+
166
+ ##
167
+ # Returns versioned path.
168
+ #
169
+ def path_versioned
170
+ content
171
+
172
+ @path_versioned
173
+ end
174
+
162
175
  ##
163
176
  # Returns appropriate HTTP headers.
164
177
  #
165
- # * +versioned+ - Uses Cache-Control header with max-age if +true+ and ETag header if +false+.
178
+ # [versioned:] Uses Cache-Control header with max-age if +true+ and ETag header if +false+.
166
179
  #
167
180
  def headers(versioned: true)
168
181
  {
169
182
  'Content-Type' => content_type,
170
183
  'Cache-Control' => ('public, max-age=31536000' if versioned),
171
- 'ETag' => ("\"#{@fingerprint}\"" if !versioned),
184
+ 'ETag' => ("\"#{fingerprint}\"" if !versioned),
172
185
  }.compact!
173
186
  end
174
187
 
175
188
  ##
176
189
  # Returns subresource integrity string.
177
190
  #
178
- # * +algorithm+ - Hash algorithm to use to generate the integrity string (one of :sha256, :sha384, or
179
- # :sha512).
191
+ # [algorithm] Hash algorithm to use to generate the integrity string (one of +:sha256+, +:sha384+, or
192
+ # +:sha512+).
180
193
  #
181
194
  def integrity(algorithm = :sha384)
182
195
  @integrity[algorithm] ||= "#{algorithm}-#{Base64.strict_encode64(
183
196
  case algorithm
184
- when :sha256 then Digest::SHA256.digest(@content)
185
- when :sha384 then Digest::SHA384.digest(@content)
186
- when :sha512 then Digest::SHA512.digest(@content)
197
+ when :sha256 then Digest::SHA256.digest(content)
198
+ when :sha384 then Digest::SHA384.digest(content)
199
+ when :sha512 then Digest::SHA512.digest(content)
187
200
  else raise("Unrecognized integrity algorithm: #{algorithm}")
188
201
  end
189
202
  )}".freeze
190
203
  end
191
204
 
192
205
  ##
193
- # Returns boolean indicating whether or not the asset is marked as internal.
206
+ # Returns full asset content.
194
207
  #
195
- def internal?
196
- @internal
197
- end
198
-
199
- ##
200
- # Returns boolean indicating whether or not an error was encountered the last time the asset was
201
- # processed.
208
+ # [minified:] Boolean indicating whether or not to return minified version if it is available.
202
209
  #
203
- def error?
204
- !!@error
210
+ def content(minified: @minify)
211
+ unless ran?(:content)
212
+ compile
213
+
214
+ @content =
215
+ if imports.empty?
216
+ @own_content
217
+ else
218
+ (0..imports.size).inject(+'') do |content, i|
219
+ own_content = (imports[i] || self).own_content
220
+
221
+ content << "\n" unless (content[-1] == "\n" && own_content[0] == "\n") || content.empty?
222
+ content << own_content
223
+ end
224
+ end
225
+
226
+ begin
227
+ finalized = @delegate.finalize_handler&.call(
228
+ parse_data: @parse_data,
229
+ path: @path,
230
+ content: @content,
231
+ )
232
+
233
+ @content = finalized if finalized.kind_of?(String)
234
+ rescue => e
235
+ @errors << e
236
+ end
237
+ end
238
+
239
+ if @delegate.minify_handler && !@content_minified && (minified || @minify)
240
+ begin
241
+ @content_minified = @delegate.minify_handler.call(
242
+ parse_data: @parse_data,
243
+ path: @path,
244
+ content: @content,
245
+ )
246
+ rescue => e
247
+ @errors << e
248
+ end
249
+ end
250
+
251
+ @fingerprint ||= Digest::MD5.hexdigest((@minify && @content_minified) || @content).freeze
252
+ @path_versioned ||= "#{@prefix}#{@path.sub(EXTENSION_REGEX, "-#{@fingerprint}")}"
253
+
254
+ (minified && @content_minified) || @content
255
+ ensure
256
+ @content.freeze
257
+ @content_minified.freeze
205
258
  end
206
259
 
207
260
  ##
@@ -209,11 +262,11 @@ class Darkroom
209
262
  #
210
263
  def inspect
211
264
  "#<#{self.class}: "\
265
+ "@entry=#{@entry.inspect}, "\
212
266
  "@errors=#{@errors.inspect}, "\
213
267
  "@extension=#{@extension.inspect}, "\
214
268
  "@file=#{@file.inspect}, "\
215
269
  "@fingerprint=#{@fingerprint.inspect}, "\
216
- "@internal=#{@internal.inspect}, "\
217
270
  "@minify=#{@minify.inspect}, "\
218
271
  "@mtime=#{@mtime.inspect}, "\
219
272
  "@path=#{@path.inspect}, "\
@@ -230,204 +283,252 @@ class Darkroom
230
283
  # error was recorded during the last processing run.
231
284
  #
232
285
  def modified?
233
- @modified_key == @darkroom.process_key ? (return @modified) : (@modified_key = @darkroom.process_key)
286
+ @modified_key == @darkroom.process_key ? (return @modified) : @modified_key = @darkroom.process_key
234
287
 
235
288
  begin
236
289
  @modified = !!@error
237
- @modified ||= @mtime != (@mtime = File.mtime(@file))
290
+ @modified ||= (@mtime != (@mtime = File.mtime(@file)))
291
+ @modified ||= @intermediate_asset.modified? if @intermediate_asset
238
292
  @modified ||= dependencies.any? { |d| d.modified? }
293
+
294
+ @ran.clear if @modified
295
+
296
+ @modified
239
297
  rescue Errno::ENOENT
240
298
  @modified = true
241
299
  end
242
300
  end
243
301
 
244
302
  ##
245
- # Returns all dependencies (including dependencies of dependencies).
246
- #
247
- # * +ignore+ - Assets already accounted for as dependency tree is walked (to prevent infinite loops when
248
- # circular chains are encountered).
303
+ # Clears content, dependencies, and errors so asset is ready for (re)processing.
249
304
  #
250
- def dependencies(ignore = nil)
251
- return @dependencies if @dependencies
305
+ def clear
306
+ return if ran?(:clear)
307
+
308
+ @own_dependencies = []
309
+ @own_imports = []
310
+ @parse_matches = []
311
+ @parse_data = {}
312
+
313
+ @dependencies = nil
314
+ @imports = nil
252
315
 
253
- dependencies = accumulate(:dependencies, ignore)
254
- @dependencies = dependencies unless ignore
316
+ @own_content = nil
317
+ @content = nil
318
+ @content_minified = nil
255
319
 
256
- dependencies
320
+ @fingerprint = nil
321
+ @path_versioned = nil
322
+ @integrity = {}
323
+
324
+ @error = nil
325
+ @errors = []
257
326
  end
258
327
 
259
328
  ##
260
- # Returns all imports (including imports of imports).
261
- #
262
- # * +ignore+ - Assets already accounted for as import tree is walked (to prevent infinite loops when
263
- # circular chains are encountered).
329
+ # Reads the asset file into memory.
264
330
  #
265
- def imports(ignore = nil)
266
- return @imports if @imports
331
+ def read
332
+ return if ran?(:read)
267
333
 
268
- imports = accumulate(:imports, ignore)
269
- @imports = imports unless ignore
334
+ clear
270
335
 
271
- imports
336
+ if @intermediate_asset
337
+ @own_content = @intermediate_asset.own_content.dup
338
+ @errors.concat(@intermediate_asset.errors)
339
+ else
340
+ begin
341
+ @own_content = File.read(@file)
342
+ rescue Errno::ENOENT
343
+ # Gracefully handle file deletion.
344
+ @own_content = ''
345
+ end
346
+ end
272
347
  end
273
348
 
274
349
  ##
275
- # Returns the processed content of the asset without dependencies concatenated.
350
+ # Parses own content to build list of imports and references.
276
351
  #
277
- def own_content
278
- @own_content
279
- end
352
+ def parse
353
+ return if ran?(:parse)
280
354
 
281
- private
355
+ read
282
356
 
283
- ##
284
- # Clears content, dependencies, and errors so asset is ready for (re)processing.
285
- #
286
- def clear
287
- @dependencies = nil
288
- @imports = nil
289
- @error = nil
290
- @fingerprint = nil
291
- @path_versioned = nil
357
+ @delegate.each_parser do |kind, regex, _|
358
+ @own_content.scan(regex) do
359
+ match = Regexp.last_match
360
+ asset = nil
292
361
 
293
- (@own_dependencies ||= []).clear
294
- (@own_imports ||= []).clear
295
- (@dependency_matches ||= []).clear
296
- (@errors ||= []).clear
297
- (@content ||= +'').clear
298
- (@own_content ||= +'').clear
299
- (@integrity ||= {}).clear
362
+ if kind == :import || kind == :reference
363
+ path = File.expand_path(match[:path], @dir)
364
+ asset = @darkroom.manifest(path)
365
+
366
+ @own_dependencies << asset if asset
367
+ @own_imports << asset if asset && kind == :import
368
+ end
369
+
370
+ @parse_matches << [kind, match, asset]
371
+ end
372
+ end
300
373
  end
301
374
 
302
375
  ##
303
- # Reads the asset file into memory.
376
+ # Returns direct dependencies (ones explicitly specified in the asset's own content.)
304
377
  #
305
- def read
306
- @own_content = File.read(@file)
378
+ def own_dependencies
379
+ parse
380
+
381
+ @own_dependencies
307
382
  end
308
383
 
309
384
  ##
310
- # Builds reference info.
385
+ # Returns all dependencies (including dependencies of dependencies).
311
386
  #
312
- def build_references
313
- return unless @delegate.reference_regex
314
-
315
- @own_content.scan(@delegate.reference_regex) do
316
- match = Regexp.last_match
317
- path = match[:path]
318
- format = match[:format]
319
- format = REFERENCE_FORMATS[match[:entity]].first if format.nil? || format == ''
320
-
321
- if (asset = @darkroom.manifest(path))
322
- if !REFERENCE_FORMATS[match[:entity]].include?(format)
323
- @errors << AssetError.new("Invalid reference format '#{format}' (must be one of "\
324
- "'#{REFERENCE_FORMATS[match[:entity]].join("', '")}')", match[0], @path, line_num(match))
325
- elsif match[:entity] == 'content' && format != 'base64' && asset.binary?
326
- @errors << AssetError.new('Base64 encoding is required for binary assets', match[0], @path,
327
- line_num(match))
328
- elsif (error = @delegate.validate_reference&.(asset, match, format))
329
- @errors << AssetError.new(error, match[0], @path, line_num(match))
330
- else
331
- @own_dependencies << asset
332
- @dependency_matches << [:reference, asset, match, format]
333
- end
334
- else
335
- @errors << not_found_error(path, match)
336
- end
387
+ def dependencies
388
+ unless ran?(:dependencies)
389
+ parse
390
+ @dependencies = accumulate(:own_dependencies)
337
391
  end
392
+
393
+ @dependencies
338
394
  end
339
395
 
340
396
  ##
341
- # Builds import info.
397
+ # Returns direct imports (ones explicitly specified in the asset's own content.)
342
398
  #
343
- def build_imports
344
- return unless @delegate.import_regex
399
+ def own_imports
400
+ parse
345
401
 
346
- @own_content.scan(@delegate.import_regex) do
347
- match = Regexp.last_match
348
- path = match[:path]
402
+ @own_imports
403
+ end
349
404
 
350
- if (asset = @darkroom.manifest(path))
351
- @own_dependencies << asset
352
- @own_imports << asset
353
- @dependency_matches << [:import, asset, match]
354
- else
355
- @errors << not_found_error(path, match)
356
- end
405
+ ##
406
+ # Returns all imports (including imports of imports).
407
+ #
408
+ def imports
409
+ unless ran?(:imports)
410
+ parse
411
+ @imports = accumulate(:own_imports)
357
412
  end
413
+
414
+ @imports
358
415
  end
359
416
 
360
417
  ##
361
- # Processes imports and references.
362
- #
363
- def process_dependencies
364
- @dependency_matches.sort_by! { |_, __, match| -match.begin(0) }.each do |kind, asset, match, format|
365
- if kind == :import
366
- @own_content[match.begin(0)...match.end(0)] = ''
367
- elsif asset.dependencies.include?(self)
368
- @errors << CircularReferenceError.new(match[0], @path, line_num(match))
369
- else
370
- value, start, finish = @delegate.reference_content&.(asset, match, format)
418
+ # Performs import and reference substitutions based on parse matches.
419
+ #
420
+ def substitute
421
+ return if ran?(:substitute)
422
+
423
+ parse
424
+ substitutions = []
425
+
426
+ @parse_matches.sort_by! { |_, match, __| match.begin(0) }.each do |kind, match, asset|
427
+ format = nil
428
+
429
+ handler = @delegate.handler(kind)
430
+ handler_args = {
431
+ parse_data: @parse_data,
432
+ match: match,
433
+ }
434
+ handler_args[:asset] = asset if asset
435
+
436
+ if !asset && (kind == :import || kind == :reference)
437
+ add_parse_error(match, AssetNotFoundError)
438
+ next
439
+ elsif kind == :reference
440
+ format = match[:format]
441
+ format = REFERENCE_FORMATS[match[:entity]].first if format.nil? || format == ''
442
+
443
+ handler_args[:format] = format
444
+
445
+ if asset.dependencies.include?(self)
446
+ add_parse_error(match, CircularReferenceError)
447
+ next
448
+ elsif !REFERENCE_FORMATS[match[:entity]].include?(format)
449
+ formats = REFERENCE_FORMATS[match[:entity]].join("', '")
450
+ add_parse_error(match, "Invalid reference format '#{format}' (must be one of '#{formats}')")
451
+ next
452
+ elsif match[:entity] == 'content' && format != 'base64' && asset.binary?
453
+ add_parse_error(match, 'Base64 encoding is required for binary assets')
454
+ next
455
+ end
456
+ end
457
+
458
+ error = catch(:error) do
459
+ substitution, start, finish = handler&.call(**handler_args)
460
+
371
461
  min_start, max_finish = match.offset(0)
372
- start ||= format == 'displace' ? min_start : match.begin(:quoted)
373
- finish ||= format == 'displace' ? max_finish : match.end(:quoted)
462
+ start ||= (!format || format == 'displace') ? min_start : match.begin(:quoted)
463
+ finish ||= (!format || format == 'displace') ? max_finish : match.end(:quoted)
374
464
  start = [[start, min_start].max, max_finish].min
375
465
  finish = [[finish, max_finish].min, min_start].max
376
466
 
377
- @own_content[start...finish] =
467
+ if kind == :reference
378
468
  case "#{match[:entity]}-#{format}"
379
469
  when 'path-versioned'
380
- value || asset.path_versioned
470
+ substitution ||= asset.path_versioned
381
471
  when 'path-unversioned'
382
- value || asset.path_unversioned
472
+ substitution ||= asset.path_unversioned
383
473
  when 'content-base64'
384
474
  quote = DEFAULT_QUOTE if match[:quote] == ''
385
- data = Base64.strict_encode64(value || asset.content)
386
- "#{quote}data:#{asset.content_type};base64,#{data}#{quote}"
475
+ data = Base64.strict_encode64(substitution || asset.content)
476
+ substitution = "#{quote}data:#{asset.content_type};base64,#{data}#{quote}"
387
477
  when 'content-utf8'
388
478
  quote = DEFAULT_QUOTE if match[:quote] == ''
389
- "#{quote}data:#{asset.content_type};utf8,#{value || asset.content}#{quote}"
479
+ data = substitution || asset.content
480
+ substitution = "#{quote}data:#{asset.content_type};utf8,#{data}#{quote}"
390
481
  when 'content-displace'
391
- value || asset.content
482
+ substitution ||= asset.content
392
483
  end
484
+ end
485
+
486
+ substitutions << [substitution || '', start, finish]
487
+ nil
393
488
  end
489
+
490
+ add_parse_error(match, error) if error
394
491
  end
395
492
 
396
- @content << imports.map { |d| d.own_content }.join(IMPORT_JOINER)
493
+ substitutions.reverse_each do |content, start, finish|
494
+ @own_content[start...finish] = content
495
+ end
397
496
  end
398
497
 
399
498
  ##
400
- # Compiles the asset if compilation is supported for the asset's type and appends the asset's own
401
- # content to the overall content string.
499
+ # Compiles the asset if compilation is supported for the asset's type.
402
500
  #
403
501
  def compile
404
- if @delegate.compile
405
- begin
406
- @own_content = @delegate.compile.(@path, @own_content)
407
- rescue => e
408
- @errors << e
409
- end
410
- end
502
+ return if ran?(:compile)
503
+
504
+ substitute
411
505
 
412
- @content << @own_content
506
+ begin
507
+ compiled = @delegate.compile_handler&.call(
508
+ parse_data: @parse_data,
509
+ path: @path,
510
+ own_content: @own_content
511
+ )
512
+
513
+ @own_content = compiled if compiled.kind_of?(String)
514
+ rescue => e
515
+ @errors << e
516
+ ensure
517
+ @own_content.freeze
518
+ end
413
519
  end
414
520
 
415
521
  ##
416
- # Minifies the asset if minification is supported for the asset's type, asset is marked as minifiable
417
- # (i.e. it's not already minified), and the asset is not marked as internal-only.
522
+ # Returns the processed content of the asset without dependencies concatenated.
418
523
  #
419
- def minify
420
- if @delegate.minify && @minify && !@internal
421
- begin
422
- @content = @delegate.minify.(@content)
423
- rescue => e
424
- @errors << e
425
- end
426
- end
524
+ def own_content
525
+ compile
427
526
 
428
- @content
527
+ @own_content
429
528
  end
430
529
 
530
+ private
531
+
431
532
  ##
432
533
  # Requires any libraries necessary for compiling and minifying the asset based on its type. Raises a
433
534
  # MissingLibraryError if library cannot be loaded.
@@ -455,46 +556,66 @@ class Darkroom
455
556
  end
456
557
 
457
558
  ##
458
- # Utility method used by #dependencies and #imports to recursively build arrays.
559
+ # Returns boolean indicating if a method has already been run during the current round of processing.
459
560
  #
460
- # * +name+ - Name of the array to accumulate (:dependencies or :imports).
461
- # * +ignore+ - Set of assets already accumulated which can be ignored (used to avoid infinite loops when
462
- # circular references are encountered).
561
+ # [name] Name of the method.
463
562
  #
464
- def accumulate(name, ignore)
465
- ignore ||= Set.new
466
- ignore << self
467
-
468
- process
469
-
470
- instance_variable_get(:"@own_#{name}").each_with_object([]) do |asset, assets|
471
- next if ignore.include?(asset)
563
+ def ran?(name)
564
+ modified?
472
565
 
473
- asset.process
474
- assets.push(*asset.send(name, ignore), asset)
475
- assets.uniq!
476
- assets.delete(self)
566
+ if @ran.member?(name)
567
+ true
568
+ else
569
+ @ran << name
570
+ false
477
571
  end
478
572
  end
479
573
 
480
574
  ##
481
- # Utility method that returns the appropriate error for a dependency that doesn't exist.
575
+ # Utility method used by #dependencies and #imports to recursively build arrays.
482
576
  #
483
- # * +path+ - Path of the asset which cannot be found.
484
- # * +match+ - MatchData object of the regex for the asset that cannot be found.
577
+ # [name] Name of the array to accumulate (:dependencies or :imports).
485
578
  #
486
- def not_found_error(path, match)
487
- klass = Darkroom.delegate(File.extname(path)) ? AssetNotFoundError : UnrecognizedExtensionError
488
- klass.new(path, @path, line_num(match))
579
+ def accumulate(name)
580
+ done = Set.new
581
+ assets = [self]
582
+ index = 0
583
+
584
+ while index
585
+ asset = assets[index]
586
+ done << asset
587
+ additional = asset.send(name).reject { |a| done.include?(a) }
588
+ assets.insert(index, *additional).uniq!
589
+ index = assets.index { |a| !done.include?(a) }
590
+ end
591
+
592
+ assets.delete(self)
593
+ assets
489
594
  end
490
595
 
491
596
  ##
492
- # Utility method that returns the line number where a regex match was found.
493
- #
494
- # * +match+ - MatchData object of the regex.
495
- #
496
- def line_num(match)
497
- @own_content[0..match.begin(:path)].count("\n") + 1
597
+ # Utility method to create a parse error of the appropriate class and append it to the errors array.
598
+ #
599
+ # [match] MatchData object for where the parse error occurred.
600
+ # [error] Error class or message.
601
+ #
602
+ def add_parse_error(match, error)
603
+ klass = error
604
+ args = []
605
+
606
+ if error == AssetNotFoundError
607
+ klass = UnrecognizedExtensionError unless Darkroom.delegate(File.extname(match[:path]))
608
+ args << match[:path]
609
+ else
610
+ if error.kind_of?(String)
611
+ klass = AssetError
612
+ args << error
613
+ end
614
+
615
+ args << match[0].strip
616
+ end
617
+
618
+ @errors << klass.new(*args, @path, @own_content[0..match.begin(:path)].count("\n") + 1)
498
619
  end
499
620
  end
500
621
  end