darkroom 0.0.5 → 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -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