darkroom 0.0.6 → 0.0.7

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
+ @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