darkroom 0.0.6 → 0.0.7

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.
@@ -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
+ @keys = {}
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,242 @@ 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
+ return @modified if ran?(:modified)
234
287
 
235
288
  begin
236
289
  @modified = !!@error
237
- @modified ||= @mtime != (@mtime = File.mtime(@file))
238
- @modified ||= dependencies.any? { |d| d.modified? }
290
+ @modified ||= (@mtime != (@mtime = File.mtime(@file)))
291
+ @modified ||= @intermediate_asset.modified? if @intermediate_asset
292
+ @modified ||= @dependencies.any? { |d| d.modified? } if @dependencies
293
+ @modified
239
294
  rescue Errno::ENOENT
240
295
  @modified = true
241
296
  end
242
297
  end
243
298
 
244
299
  ##
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).
300
+ # Clears content, dependencies, and errors so asset is ready for (re)processing.
249
301
  #
250
- def dependencies(ignore = nil)
251
- return @dependencies if @dependencies
302
+ def clear
303
+ return if ran?(:clear)
252
304
 
253
- dependencies = accumulate(:dependencies, ignore)
254
- @dependencies = dependencies unless ignore
305
+ @own_dependencies = []
306
+ @own_imports = []
307
+ @parse_matches = []
308
+ @parse_data = {}
255
309
 
256
- dependencies
310
+ @dependencies = nil
311
+ @imports = nil
312
+
313
+ @own_content = nil
314
+ @content = nil
315
+ @content_minified = nil
316
+
317
+ @fingerprint = nil
318
+ @path_versioned = nil
319
+ @integrity = {}
320
+
321
+ @error = nil
322
+ @errors = []
257
323
  end
258
324
 
259
325
  ##
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).
326
+ # Reads the asset file into memory.
264
327
  #
265
- def imports(ignore = nil)
266
- return @imports if @imports
328
+ def read
329
+ return if ran?(:read)
267
330
 
268
- imports = accumulate(:imports, ignore)
269
- @imports = imports unless ignore
331
+ clear
270
332
 
271
- imports
333
+ if @intermediate_asset
334
+ @own_content = @intermediate_asset.own_content.dup
335
+ @errors.concat(@intermediate_asset.errors)
336
+ else
337
+ begin
338
+ @own_content = File.read(@file)
339
+ rescue Errno::ENOENT
340
+ # Gracefully handle file deletion.
341
+ @own_content = ''
342
+ end
343
+ end
272
344
  end
273
345
 
274
346
  ##
275
- # Returns the processed content of the asset without dependencies concatenated.
347
+ # Parses own content to build list of imports and references.
276
348
  #
277
- def own_content
278
- @own_content
279
- end
349
+ def parse
350
+ return if ran?(:parse)
280
351
 
281
- private
352
+ read
353
+
354
+ @delegate.each_parser do |kind, regex, _|
355
+ @own_content.scan(regex) do
356
+ match = Regexp.last_match
357
+ asset = nil
358
+
359
+ if kind == :import || kind == :reference
360
+ path = File.expand_path(match[:path], @dir)
361
+ asset = @darkroom.manifest(path)
362
+
363
+ @own_dependencies << asset if asset
364
+ @own_imports << asset if asset && kind == :import
365
+ end
366
+
367
+ @parse_matches << [kind, match, asset]
368
+ end
369
+ end
370
+ end
282
371
 
283
372
  ##
284
- # Clears content, dependencies, and errors so asset is ready for (re)processing.
373
+ # Returns direct dependencies (ones explicitly specified in the asset's own content.)
285
374
  #
286
- def clear
287
- @dependencies = nil
288
- @imports = nil
289
- @error = nil
290
- @fingerprint = nil
291
- @path_versioned = nil
375
+ def own_dependencies
376
+ parse
292
377
 
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
378
+ @own_dependencies
300
379
  end
301
380
 
302
381
  ##
303
- # Reads the asset file into memory.
382
+ # Returns all dependencies (including dependencies of dependencies).
304
383
  #
305
- def read
306
- @own_content = File.read(@file)
384
+ def dependencies
385
+ @dependencies = accumulate(:own_dependencies) unless ran?(:dependencies)
386
+ @dependencies
307
387
  end
308
388
 
309
389
  ##
310
- # Builds reference info.
390
+ # Returns direct imports (ones explicitly specified in the asset's own content.)
311
391
  #
312
- def build_references
313
- return unless @delegate.reference_regex
392
+ def own_imports
393
+ parse
314
394
 
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
337
- end
395
+ @own_imports
338
396
  end
339
397
 
340
398
  ##
341
- # Builds import info.
399
+ # Returns all imports (including imports of imports).
342
400
  #
343
- def build_imports
344
- return unless @delegate.import_regex
345
-
346
- @own_content.scan(@delegate.import_regex) do
347
- match = Regexp.last_match
348
- path = match[:path]
349
-
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
357
- end
401
+ def imports
402
+ @imports = accumulate(:own_imports) unless ran?(:imports)
403
+ @imports
358
404
  end
359
405
 
360
406
  ##
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)
407
+ # Performs import and reference substitutions based on parse matches.
408
+ #
409
+ def substitute
410
+ return if ran?(:substitute)
411
+
412
+ parse
413
+ substitutions = []
414
+
415
+ @parse_matches.sort_by! { |_, match, __| match.begin(0) }.each do |kind, match, asset|
416
+ format = nil
417
+
418
+ handler = @delegate.handler(kind)
419
+ handler_args = {
420
+ parse_data: @parse_data,
421
+ match: match,
422
+ }
423
+ handler_args[:asset] = asset if asset
424
+
425
+ if !asset && (kind == :import || kind == :reference)
426
+ add_parse_error(match, AssetNotFoundError)
427
+ next
428
+ elsif kind == :reference
429
+ format = match[:format]
430
+ format = REFERENCE_FORMATS[match[:entity]].first if format.nil? || format == ''
431
+
432
+ handler_args[:format] = format
433
+
434
+ if asset.dependencies.include?(self)
435
+ add_parse_error(match, CircularReferenceError)
436
+ next
437
+ elsif !REFERENCE_FORMATS[match[:entity]].include?(format)
438
+ formats = REFERENCE_FORMATS[match[:entity]].join("', '")
439
+ add_parse_error(match, "Invalid reference format '#{format}' (must be one of '#{formats}')")
440
+ next
441
+ elsif match[:entity] == 'content' && format != 'base64' && asset.binary?
442
+ add_parse_error(match, 'Base64 encoding is required for binary assets')
443
+ next
444
+ end
445
+ end
446
+
447
+ error = catch(:error) do
448
+ substitution, start, finish = handler&.call(**handler_args)
449
+
371
450
  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)
451
+ start ||= (!format || format == 'displace') ? min_start : match.begin(:quoted)
452
+ finish ||= (!format || format == 'displace') ? max_finish : match.end(:quoted)
374
453
  start = [[start, min_start].max, max_finish].min
375
454
  finish = [[finish, max_finish].min, min_start].max
376
455
 
377
- @own_content[start...finish] =
456
+ if kind == :reference
378
457
  case "#{match[:entity]}-#{format}"
379
458
  when 'path-versioned'
380
- value || asset.path_versioned
459
+ substitution ||= asset.path_versioned
381
460
  when 'path-unversioned'
382
- value || asset.path_unversioned
461
+ substitution ||= asset.path_unversioned
383
462
  when 'content-base64'
384
463
  quote = DEFAULT_QUOTE if match[:quote] == ''
385
- data = Base64.strict_encode64(value || asset.content)
386
- "#{quote}data:#{asset.content_type};base64,#{data}#{quote}"
464
+ data = Base64.strict_encode64(substitution || asset.content)
465
+ substitution = "#{quote}data:#{asset.content_type};base64,#{data}#{quote}"
387
466
  when 'content-utf8'
388
467
  quote = DEFAULT_QUOTE if match[:quote] == ''
389
- "#{quote}data:#{asset.content_type};utf8,#{value || asset.content}#{quote}"
468
+ data = substitution || asset.content
469
+ substitution = "#{quote}data:#{asset.content_type};utf8,#{data}#{quote}"
390
470
  when 'content-displace'
391
- value || asset.content
471
+ substitution ||= asset.content
392
472
  end
473
+ end
474
+
475
+ substitutions << [substitution || '', start, finish]
476
+ nil
393
477
  end
478
+
479
+ add_parse_error(match, error) if error
394
480
  end
395
481
 
396
- @content << imports.map { |d| d.own_content }.join(IMPORT_JOINER)
482
+ substitutions.reverse_each do |content, start, finish|
483
+ @own_content[start...finish] = content
484
+ end
397
485
  end
398
486
 
399
487
  ##
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.
488
+ # Compiles the asset if compilation is supported for the asset's type.
402
489
  #
403
490
  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
491
+ return if ran?(:compile)
492
+
493
+ substitute
411
494
 
412
- @content << @own_content
495
+ begin
496
+ compiled = @delegate.compile_handler&.call(
497
+ parse_data: @parse_data,
498
+ path: @path,
499
+ own_content: @own_content
500
+ )
501
+
502
+ @own_content = compiled if compiled.kind_of?(String)
503
+ rescue => e
504
+ @errors << e
505
+ end
506
+ ensure
507
+ @own_content.freeze
508
+ dependencies # Ensure dependency array gets built.
413
509
  end
414
510
 
415
511
  ##
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.
512
+ # Returns the processed content of the asset without dependencies concatenated.
418
513
  #
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
514
+ def own_content
515
+ compile
427
516
 
428
- @content
517
+ @own_content
429
518
  end
430
519
 
520
+ private
521
+
431
522
  ##
432
523
  # Requires any libraries necessary for compiling and minifying the asset based on its type. Raises a
433
524
  # MissingLibraryError if library cannot be loaded.
@@ -455,46 +546,64 @@ class Darkroom
455
546
  end
456
547
 
457
548
  ##
458
- # Utility method used by #dependencies and #imports to recursively build arrays.
549
+ # Returns boolean indicating if a method has already been run during the current round of processing.
459
550
  #
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).
551
+ # [name] Name of the method.
463
552
  #
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)
472
-
473
- asset.process
474
- assets.push(*asset.send(name, ignore), asset)
475
- assets.uniq!
476
- assets.delete(self)
553
+ def ran?(name)
554
+ if @keys[name] == @darkroom.process_key
555
+ true
556
+ else
557
+ @keys[name] = @darkroom.process_key
558
+ name == :modified ? false : !modified?
477
559
  end
478
560
  end
479
561
 
480
562
  ##
481
- # Utility method that returns the appropriate error for a dependency that doesn't exist.
563
+ # Utility method used by #dependencies and #imports to recursively build arrays.
482
564
  #
483
- # * +path+ - Path of the asset which cannot be found.
484
- # * +match+ - MatchData object of the regex for the asset that cannot be found.
565
+ # [name] Name of the array to accumulate (:dependencies or :imports).
485
566
  #
486
- def not_found_error(path, match)
487
- klass = Darkroom.delegate(File.extname(path)) ? AssetNotFoundError : UnrecognizedExtensionError
488
- klass.new(path, @path, line_num(match))
567
+ def accumulate(name)
568
+ done = Set.new
569
+ assets = [self]
570
+ index = 0
571
+
572
+ while index
573
+ asset = assets[index]
574
+ done << asset
575
+ additional = asset.send(name).reject { |a| done.include?(a) }
576
+ assets.insert(index, *additional).uniq!
577
+ index = assets.index { |a| !done.include?(a) }
578
+ end
579
+
580
+ assets.delete(self)
581
+ assets
489
582
  end
490
583
 
491
584
  ##
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
585
+ # Utility method to create a parse error of the appropriate class and append it to the errors array.
586
+ #
587
+ # [match] MatchData object for where the parse error occurred.
588
+ # [error] Error class or message.
589
+ #
590
+ def add_parse_error(match, error)
591
+ klass = error
592
+ args = []
593
+
594
+ if error == AssetNotFoundError
595
+ klass = UnrecognizedExtensionError unless Darkroom.delegate(File.extname(match[:path]))
596
+ args << match[:path]
597
+ else
598
+ if error.kind_of?(String)
599
+ klass = AssetError
600
+ args << error
601
+ end
602
+
603
+ args << match[0].strip
604
+ end
605
+
606
+ @errors << klass.new(*args, @path, @own_content[0..match.begin(:path)].count("\n") + 1)
498
607
  end
499
608
  end
500
609
  end