darkroom 0.0.5 → 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,12 @@ require('base64')
4
4
  require('digest')
5
5
  require('set')
6
6
 
7
- require_relative('darkroom')
7
+ require_relative('errors/asset_error')
8
+ require_relative('errors/asset_not_found_error')
9
+ require_relative('errors/circular_reference_error')
10
+ require_relative('errors/missing_library_error')
11
+ require_relative('errors/processing_error')
12
+ require_relative('errors/unrecognized_extension_error')
8
13
 
9
14
  class Darkroom
10
15
  ##
@@ -12,18 +17,16 @@ class Darkroom
12
17
  #
13
18
  class Asset
14
19
  EXTENSION_REGEX = /(?=\.\w+)/.freeze
15
-
16
- IMPORT_JOINER = "\n"
17
20
  DEFAULT_QUOTE = '\''
18
-
19
- QUOTED_PATH = /(?<quote>['"])(?<path>[^'"]*)\k<quote>/.freeze
20
- REFERENCE_PATH =
21
- %r{
22
- (?<quote>['"]?)(?<quoted>
23
- (?<path>[^#{DISALLOWED_PATH_CHARS}]+)
24
- \?asset-(?<entity>path|content)(=(?<format>\w*))?
25
- )\k<quote>
26
- }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
27
30
 
28
31
  # First item of each set is used as default, so order is important.
29
32
  REFERENCE_FORMATS = {
@@ -31,94 +34,60 @@ class Darkroom
31
34
  'content' => Set.new(%w[base64 utf8 displace]),
32
35
  }.freeze
33
36
 
34
- attr_reader(:content, :error, :errors, :path, :path_unversioned, :path_versioned)
35
-
36
- ##
37
- # Holds information about how to handle a particular asset type.
38
- #
39
- # * +content_type+ - HTTP MIME type string.
40
- # * +import_regex+ - Regex to find import statements. Must contain a named component called 'path'
41
- # (e.g. +/^import (?<path>.*)/+).
42
- # * +reference_regex+ - Regex to find references to other assets. Must contain three named components:
43
- # * +path+ - Path of the asset being referenced.
44
- # * +entity+ - Desired entity (path or content).
45
- # * +format+ - Format to use (see REFERENCE_FORMATS).
46
- # * +validate_reference+ - Lambda to call to validate a reference. Should return nil if there are no
47
- # errors and a string error message if validation fails. Three arguments are passed when called:
48
- # * +asset+ - Asset object of the asset being referenced.
49
- # * +match+ - MatchData object from the match against +reference_regex+.
50
- # * +format+ - Format of the reference (see REFERENCE_FORMATS).
51
- # * +reference_content+ - Lambda to call to get the content for a reference. Should return nil if the
52
- # default behavior is desired or a string for custom content. Three arguments are passed when called:
53
- # * +asset+ - Asset object of the asset being referenced.
54
- # * +match+ - MatchData object from the match against +reference_regex+.
55
- # * +format+ - Format of the reference (see REFERENCE_FORMATS).
56
- # * +compile_lib+ - Name of a library to +require+ that is needed by the +compile+ lambda.
57
- # * +compile+ - Lambda to call that will return the compiled version of the asset's content. Two
58
- # arguments are passed when called:
59
- # * +path+ - Path of the asset being compiled.
60
- # * +content+ - Content to compile.
61
- # * +minify_lib+ - Name of a library to +require+ that is needed by the +minify+ lambda.
62
- # * +minify+ - Lambda to call that will return the minified version of the asset's content. One argument
63
- # is passed when called:
64
- # * +content+ - Content to minify.
65
- #
66
- Delegate = Struct.new(:content_type, :import_regex, :reference_regex, :validate_reference,
67
- :reference_content, :compile_lib, :compile, :minify_lib, :minify, keyword_init: true)
37
+ attr_reader(:errors, :path, :path_unversioned)
68
38
 
69
39
  ##
70
40
  # Creates a new instance.
71
41
  #
72
- # * +file+ - Path of file on disk.
73
- # * +path+ - Path this asset will be referenced by (e.g. /js/app.js).
74
- # * +darkroom+ - Darkroom instance that the asset is a member of.
75
- # * +prefix+ - Prefix to apply to unversioned and versioned paths.
76
- # * +minify+ - Boolean specifying whether or not the asset should be minified when processed.
77
- # * +internal+ - Boolean indicating whether or not the asset is only accessible internally (i.e. as an
78
- # 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.
79
50
  #
80
- 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)
81
52
  @path = path
53
+ @dir = File.dirname(path)
82
54
  @file = file
83
55
  @darkroom = darkroom
84
56
  @prefix = prefix
57
+ @entry = entry
85
58
  @minify = minify
86
- @internal = internal
87
59
 
88
60
  @path_unversioned = "#{@prefix}#{@path}"
89
61
  @extension = File.extname(@path).downcase
90
62
  @delegate = Darkroom.delegate(@extension) or raise(UnrecognizedExtensionError.new(@path))
91
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
+
92
76
  require_libs
93
77
  clear
94
78
  end
95
79
 
96
80
  ##
97
- # Processes the asset if modified (see #modified? for how modification is determined). File is read from
98
- # disk, references are substituted (if supported), content is compiled (if required), imports are
99
- # prefixed to its content (if supported), and content is minified (if supported and enabled). Returns
100
- # 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).
101
85
  #
102
86
  def process
103
- @process_key == @darkroom.process_key ? (return @processed) : (@process_key = @darkroom.process_key)
104
- modified? ? (@processed = true) : (return @processed = false)
87
+ return if ran?(:process)
105
88
 
106
- clear
107
- read
108
- build_imports
109
- build_references
110
- process_dependencies
111
89
  compile
112
- minify
113
-
114
- @fingerprint = Digest::MD5.hexdigest(@content)
115
- @path_versioned = "#{@prefix}#{@path.sub(EXTENSION_REGEX, "-#{@fingerprint}")}"
116
-
117
- @processed
118
- rescue Errno::ENOENT
119
- # File was deleted. Do nothing.
120
- ensure
121
- @error = @errors.empty? ? nil : ProcessingError.new(@errors)
90
+ content if entry?
122
91
  end
123
92
 
124
93
  ##
@@ -153,49 +122,139 @@ class Darkroom
153
122
  defined?(@is_image) ? @is_image : (@is_image = content_type.start_with?('image/'))
154
123
  end
155
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
+
156
175
  ##
157
176
  # Returns appropriate HTTP headers.
158
177
  #
159
- # * +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+.
160
179
  #
161
180
  def headers(versioned: true)
162
181
  {
163
182
  'Content-Type' => content_type,
164
183
  'Cache-Control' => ('public, max-age=31536000' if versioned),
165
- 'ETag' => ("\"#{@fingerprint}\"" if !versioned),
184
+ 'ETag' => ("\"#{fingerprint}\"" if !versioned),
166
185
  }.compact!
167
186
  end
168
187
 
169
188
  ##
170
189
  # Returns subresource integrity string.
171
190
  #
172
- # * +algorithm+ - Hash algorithm to use to generate the integrity string (one of :sha256, :sha384, or
173
- # :sha512).
191
+ # [algorithm] Hash algorithm to use to generate the integrity string (one of +:sha256+, +:sha384+, or
192
+ # +:sha512+).
174
193
  #
175
194
  def integrity(algorithm = :sha384)
176
195
  @integrity[algorithm] ||= "#{algorithm}-#{Base64.strict_encode64(
177
196
  case algorithm
178
- when :sha256 then Digest::SHA256.digest(@content)
179
- when :sha384 then Digest::SHA384.digest(@content)
180
- 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)
181
200
  else raise("Unrecognized integrity algorithm: #{algorithm}")
182
201
  end
183
202
  )}".freeze
184
203
  end
185
204
 
186
205
  ##
187
- # Returns boolean indicating whether or not the asset is marked as internal.
206
+ # Returns full asset content.
188
207
  #
189
- def internal?
190
- @internal
191
- end
192
-
193
- ##
194
- # Returns boolean indicating whether or not an error was encountered the last time the asset was
195
- # processed.
208
+ # [minified:] Boolean indicating whether or not to return minified version if it is available.
196
209
  #
197
- def error?
198
- !!@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
199
258
  end
200
259
 
201
260
  ##
@@ -203,11 +262,11 @@ class Darkroom
203
262
  #
204
263
  def inspect
205
264
  "#<#{self.class}: "\
265
+ "@entry=#{@entry.inspect}, "\
206
266
  "@errors=#{@errors.inspect}, "\
207
267
  "@extension=#{@extension.inspect}, "\
208
268
  "@file=#{@file.inspect}, "\
209
269
  "@fingerprint=#{@fingerprint.inspect}, "\
210
- "@internal=#{@internal.inspect}, "\
211
270
  "@minify=#{@minify.inspect}, "\
212
271
  "@mtime=#{@mtime.inspect}, "\
213
272
  "@path=#{@path.inspect}, "\
@@ -224,194 +283,242 @@ class Darkroom
224
283
  # error was recorded during the last processing run.
225
284
  #
226
285
  def modified?
227
- @modified_key == @darkroom.process_key ? (return @modified) : (@modified_key = @darkroom.process_key)
286
+ return @modified if ran?(:modified)
228
287
 
229
288
  begin
230
289
  @modified = !!@error
231
- @modified ||= @mtime != (@mtime = File.mtime(@file))
232
- @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
233
294
  rescue Errno::ENOENT
234
295
  @modified = true
235
296
  end
236
297
  end
237
298
 
238
- ##
239
- # Returns all dependencies (including dependencies of dependencies).
240
- #
241
- # * +ignore+ - Assets already accounted for as dependency tree is walked (to prevent infinite loops when
242
- # circular chains are encountered).
243
- #
244
- def dependencies(ignore = Set.new)
245
- @dependencies ||= accumulate(:dependencies, ignore)
246
- end
247
-
248
- ##
249
- # Returns all imports (including imports of imports).
250
- #
251
- # * +ignore+ - Assets already accounted for as import tree is walked (to prevent infinite loops when
252
- # circular chains are encountered).
253
- #
254
- def imports(ignore = Set.new)
255
- @imports ||= accumulate(:imports, ignore)
256
- end
257
-
258
- ##
259
- # Returns the processed content of the asset without dependencies concatenated.
260
- #
261
- def own_content
262
- @own_content
263
- end
264
-
265
- private
266
-
267
299
  ##
268
300
  # Clears content, dependencies, and errors so asset is ready for (re)processing.
269
301
  #
270
302
  def clear
303
+ return if ran?(:clear)
304
+
305
+ @own_dependencies = []
306
+ @own_imports = []
307
+ @parse_matches = []
308
+ @parse_data = {}
309
+
271
310
  @dependencies = nil
272
311
  @imports = nil
273
- @error = nil
312
+
313
+ @own_content = nil
314
+ @content = nil
315
+ @content_minified = nil
316
+
274
317
  @fingerprint = nil
275
318
  @path_versioned = nil
319
+ @integrity = {}
276
320
 
277
- (@own_dependencies ||= []).clear
278
- (@own_imports ||= []).clear
279
- (@dependency_matches ||= []).clear
280
- (@errors ||= []).clear
281
- (@content ||= +'').clear
282
- (@own_content ||= +'').clear
283
- (@integrity ||= {}).clear
321
+ @error = nil
322
+ @errors = []
284
323
  end
285
324
 
286
325
  ##
287
326
  # Reads the asset file into memory.
288
327
  #
289
328
  def read
290
- @own_content = File.read(@file)
329
+ return if ran?(:read)
330
+
331
+ clear
332
+
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
291
344
  end
292
345
 
293
346
  ##
294
- # Builds reference info.
347
+ # Parses own content to build list of imports and references.
295
348
  #
296
- def build_references
297
- return unless @delegate.reference_regex
349
+ def parse
350
+ return if ran?(:parse)
351
+
352
+ read
298
353
 
299
- @own_content.scan(@delegate.reference_regex) do
300
- match = Regexp.last_match
301
- path = match[:path]
302
- format = match[:format]
303
- format = REFERENCE_FORMATS[match[:entity]].first if format.nil? || format == ''
354
+ @delegate.each_parser do |kind, regex, _|
355
+ @own_content.scan(regex) do
356
+ match = Regexp.last_match
357
+ asset = nil
304
358
 
305
- if (asset = @darkroom.manifest(path))
306
- if !REFERENCE_FORMATS[match[:entity]].include?(format)
307
- @errors << AssetError.new("Invalid reference format '#{format}' (must be one of "\
308
- "'#{REFERENCE_FORMATS[match[:entity]].join("', '")}')", match[0], @path, line_num(match))
309
- elsif match[:entity] == 'content' && format != 'base64' && asset.binary?
310
- @errors << AssetError.new('Base64 encoding is required for binary assets', match[0], @path,
311
- line_num(match))
312
- elsif (error = @delegate.validate_reference&.(asset, match, format))
313
- @errors << AssetError.new(error, match[0], @path, line_num(match))
314
- else
315
- @own_dependencies << asset
316
- @dependency_matches << [:reference, asset, match, format]
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
317
365
  end
318
- else
319
- @errors << not_found_error(path, match)
366
+
367
+ @parse_matches << [kind, match, asset]
320
368
  end
321
369
  end
322
370
  end
323
371
 
324
372
  ##
325
- # Builds import info.
373
+ # Returns direct dependencies (ones explicitly specified in the asset's own content.)
326
374
  #
327
- def build_imports
328
- return unless @delegate.import_regex
375
+ def own_dependencies
376
+ parse
329
377
 
330
- @own_content.scan(@delegate.import_regex) do
331
- match = Regexp.last_match
332
- path = match[:path]
378
+ @own_dependencies
379
+ end
333
380
 
334
- if (asset = @darkroom.manifest(path))
335
- @own_dependencies << asset
336
- @own_imports << asset
337
- @dependency_matches << [:import, asset, match]
338
- else
339
- @errors << not_found_error(path, match)
340
- end
341
- end
381
+ ##
382
+ # Returns all dependencies (including dependencies of dependencies).
383
+ #
384
+ def dependencies
385
+ @dependencies = accumulate(:own_dependencies) unless ran?(:dependencies)
386
+ @dependencies
387
+ end
388
+
389
+ ##
390
+ # Returns direct imports (ones explicitly specified in the asset's own content.)
391
+ #
392
+ def own_imports
393
+ parse
394
+
395
+ @own_imports
396
+ end
397
+
398
+ ##
399
+ # Returns all imports (including imports of imports).
400
+ #
401
+ def imports
402
+ @imports = accumulate(:own_imports) unless ran?(:imports)
403
+ @imports
342
404
  end
343
405
 
344
406
  ##
345
- # Processes imports and references.
346
- #
347
- def process_dependencies
348
- @dependency_matches.sort_by! { |_, __, match| -match.begin(0) }.each do |kind, asset, match, format|
349
- if kind == :import
350
- @own_content[match.begin(0)...match.end(0)] = ''
351
- elsif asset.dependencies.include?(self)
352
- @errors << CircularReferenceError.new(match[0], @path, line_num(match))
353
- else
354
- 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
+
355
450
  min_start, max_finish = match.offset(0)
356
- start ||= format == 'displace' ? min_start : match.begin(:quoted)
357
- 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)
358
453
  start = [[start, min_start].max, max_finish].min
359
454
  finish = [[finish, max_finish].min, min_start].max
360
455
 
361
- @own_content[start...finish] =
456
+ if kind == :reference
362
457
  case "#{match[:entity]}-#{format}"
363
458
  when 'path-versioned'
364
- value || asset.path_versioned
459
+ substitution ||= asset.path_versioned
365
460
  when 'path-unversioned'
366
- value || asset.path_unversioned
461
+ substitution ||= asset.path_unversioned
367
462
  when 'content-base64'
368
463
  quote = DEFAULT_QUOTE if match[:quote] == ''
369
- data = Base64.strict_encode64(value || asset.content)
370
- "#{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}"
371
466
  when 'content-utf8'
372
467
  quote = DEFAULT_QUOTE if match[:quote] == ''
373
- "#{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}"
374
470
  when 'content-displace'
375
- value || asset.content
471
+ substitution ||= asset.content
376
472
  end
473
+ end
474
+
475
+ substitutions << [substitution || '', start, finish]
476
+ nil
377
477
  end
478
+
479
+ add_parse_error(match, error) if error
378
480
  end
379
481
 
380
- @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
381
485
  end
382
486
 
383
487
  ##
384
- # Compiles the asset if compilation is supported for the asset's type and appends the asset's own
385
- # content to the overall content string.
488
+ # Compiles the asset if compilation is supported for the asset's type.
386
489
  #
387
490
  def compile
388
- if @delegate.compile
389
- begin
390
- @own_content = @delegate.compile.(@path, @own_content)
391
- rescue => e
392
- @errors << e
393
- end
394
- end
491
+ return if ran?(:compile)
395
492
 
396
- @content << @own_content
493
+ substitute
494
+
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.
397
509
  end
398
510
 
399
511
  ##
400
- # Minifies the asset if minification is supported for the asset's type, asset is marked as minifiable
401
- # (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.
402
513
  #
403
- def minify
404
- if @delegate.minify && @minify && !@internal
405
- begin
406
- @content = @delegate.minify.(@content)
407
- rescue => e
408
- @errors << e
409
- end
410
- end
514
+ def own_content
515
+ compile
411
516
 
412
- @content
517
+ @own_content
413
518
  end
414
519
 
520
+ private
521
+
415
522
  ##
416
523
  # Requires any libraries necessary for compiling and minifying the asset based on its type. Raises a
417
524
  # MissingLibraryError if library cannot be loaded.
@@ -419,7 +526,7 @@ class Darkroom
419
526
  # Darkroom does not explicitly depend on any libraries necessary for asset compilation or minification,
420
527
  # since not every app will use every kind of asset or use minification. It is instead up to each app
421
528
  # using Darkroom to specify any needed compilation and minification libraries as direct dependencies
422
- # (e.g. specify +gem('uglifier')+ in the app's Gemfile if JavaScript minification is desired).
529
+ # (e.g. specify +gem('terser')+ in the app's Gemfile if JavaScript minification is desired).
423
530
  #
424
531
  def require_libs
425
532
  begin
@@ -439,44 +546,64 @@ class Darkroom
439
546
  end
440
547
 
441
548
  ##
442
- # 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.
443
550
  #
444
- # * +name+ - Name of the array to accumulate (:dependencies or :imports).
445
- # * +ignore+ - Set of assets already accumulated which can be ignored (used to avoid infinite loops when
446
- # circular references are encountered).
551
+ # [name] Name of the method.
447
552
  #
448
- def accumulate(name, ignore)
449
- ignore << self
450
- process
451
-
452
- instance_variable_get(:"@own_#{name}").each_with_object([]) do |asset, assets|
453
- next if ignore.include?(asset)
454
-
455
- asset.process
456
- assets.push(*asset.send(name, ignore), asset)
457
- assets.uniq!
458
- 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?
459
559
  end
460
560
  end
461
561
 
462
562
  ##
463
- # 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.
464
564
  #
465
- # * +path+ - Path of the asset which cannot be found.
466
- # * +match+ - MatchData object of the regex for the asset that cannot be found.
565
+ # [name] Name of the array to accumulate (:dependencies or :imports).
467
566
  #
468
- def not_found_error(path, match)
469
- klass = Darkroom.delegate(File.extname(path)) ? AssetNotFoundError : UnrecognizedExtensionError
470
- 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
471
582
  end
472
583
 
473
584
  ##
474
- # Utility method that returns the line number where a regex match was found.
475
- #
476
- # * +match+ - MatchData object of the regex.
477
- #
478
- def line_num(match)
479
- @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)
480
607
  end
481
608
  end
482
609
  end