klenod-build 0.0.13 → 0.0.14

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b99708644a67ef46ca03814e2ea175347cfdea450ac8ac33d53e8a7b0c9a9f4
4
- data.tar.gz: f7b5935d6d9ddcbd442dfdb95873c5e23fe3ed5c28ab891c4c5996b26ce8b9f0
3
+ metadata.gz: af646580bbf44262cf5f2262b5fd81eb7a77eeb94498d7ae00e44383cc26ce42
4
+ data.tar.gz: 6c84bd8dcc4d42be762dabd59bcb918d3aa42418772bd54f68b55e5cd0f3d17d
5
5
  SHA512:
6
- metadata.gz: 1cf296164f848ae53f748d9dff80dffcccaae0abcbad3eee0b0071366042ae5ad3c66069526baadb02ec34154b90453c0cf464998613fd286293f7b896b88108
7
- data.tar.gz: 726527a490d7dc39cd1eb13515f80351dcdbfcf1735519131a52c74ac046b041580dcf131090e3484a17d98c385dc750ab721a3688b5f54ae8c98f9b10610bd8
6
+ metadata.gz: ce727e4c814158c472b04f3fed395faa53c812d9b6b397ea7bdf57f7c30305459fb057631021cdaf83979860f252d91f7c3d86ac2fde9518d92f983c284cb900
7
+ data.tar.gz: 72bc4a860585664b9b9c210d89676c50feb0a594d10120d788516ef7cea0c735e983e25145aa89f022b42d6fee0df8064f6f69ac15b45e8c86bd78b55464f389
@@ -28,6 +28,7 @@ module Klenod
28
28
  mods.delete(module_id)
29
29
  end
30
30
 
31
+ failed_reload_ids = []
31
32
  reloaded_module_ids =
32
33
  reload_module_ids.filter_map do |module_id|
33
34
  if evaluated_module_ids.include?(module_id)
@@ -36,12 +37,15 @@ module Klenod
36
37
  graph.collect_module(module_id, force: true)
37
38
  end
38
39
  module_id
39
- rescue => e
40
+ rescue StandardError, ScriptError => e
41
+ # ScriptError too: a syntax error in a module is not a
42
+ # StandardError, and letting it escape here kills the watcher
43
+ # thread rather than reporting the module that failed.
40
44
  mark_module_failed(module_id, e)
41
- errors << [module_id, e]
45
+ failed_reload_ids << module_id
46
+ record_error(errors, module_id, e)
42
47
  nil
43
48
  end
44
- failed_reload_ids = errors.map(&:first) & reload_module_ids
45
49
  blocked_dependent_ids = dependent_closure(failed_reload_ids)
46
50
  blocked_dependent_ids.each { |module_id| mods.delete(module_id) }
47
51
 
@@ -58,8 +62,8 @@ module Klenod
58
62
  graph.collect_module(module_id, force: true)
59
63
  nil
60
64
  end
61
- rescue => e
62
- errors << [module_id, e]
65
+ rescue StandardError, ScriptError => e
66
+ record_error(errors, module_id, e)
63
67
  nil
64
68
  end
65
69
  asset_updates = diff_assets(previous_assets, graph.assets)
@@ -80,6 +84,16 @@ module Klenod
80
84
 
81
85
  private
82
86
 
87
+ # One failure, reported once. A module whose dependency already failed
88
+ # in this invalidation re-raises that same error when it reloads, so a
89
+ # broken companion would otherwise be reported once for itself and
90
+ # again for every module that imports it.
91
+ def record_error(errors, module_id, error)
92
+ return if errors.any? { |(_id, recorded)| recorded.equal?(error) }
93
+
94
+ errors << [module_id, error]
95
+ end
96
+
83
97
  attr_reader :graph, :resolver, :source_loader
84
98
 
85
99
  def records
@@ -7,6 +7,7 @@ require "klenod/runtime/mod"
7
7
  require "klenod/runtime/bundle"
8
8
  require_relative "asset_generation_queue"
9
9
  require_relative "errors"
10
+ require_relative "source_error"
10
11
  require_relative "graph/invalidator"
11
12
  require_relative "hashing"
12
13
  require_relative "invalidation_result"
@@ -24,9 +25,12 @@ module Klenod
24
25
  include TSort
25
26
 
26
27
  AsyncResult = Data.define(:value, :error) do
28
+ # ScriptError too, so a syntax error in a module comes back to the
29
+ # parent graph path instead of surfacing as an unhandled Async::Task
30
+ # exception.
27
31
  def self.capture
28
32
  new(yield, nil)
29
- rescue => e
33
+ rescue StandardError, ScriptError => e
30
34
  new(nil, e)
31
35
  end
32
36
 
@@ -281,8 +285,9 @@ module Klenod
281
285
  dependency_records = load_eager_dependency_records(resolved_dependencies)
282
286
  transform = finalize_transform_result(module_id, transform, resolved_dependencies, dependency_records)
283
287
  assert_supported_transform!(module_id, source, transform)
288
+ assert_generated_ruby_parses!(module_id, transform)
284
289
  transformed_hash = Hashing.hexdigest(transform.code)
285
- mod = instantiate_module(module_id, transform, resolved_dependencies, dependency_records, cached)
290
+ mod = instantiate_module(module_id, transform, resolved_dependencies, dependency_records, cached, source)
286
291
  record = build_module_record(module_id, source, source_hash, transformed_hash, transform, resolved_dependencies, mod)
287
292
 
288
293
  @records[module_id] = record
@@ -336,6 +341,7 @@ module Klenod
336
341
  dependency_records = collect_eager_dependency_records(resolved_dependencies)
337
342
  transform = finalize_transform_result(module_id, transform, resolved_dependencies, dependency_records)
338
343
  assert_supported_transform!(module_id, source, transform)
344
+ assert_generated_ruby_parses!(module_id, transform)
339
345
  transformed_hash = Hashing.hexdigest(transform.code)
340
346
  record = build_module_record(module_id, source, source_hash, transformed_hash, transform, resolved_dependencies, cached)
341
347
 
@@ -347,22 +353,31 @@ module Klenod
347
353
  end
348
354
 
349
355
  def evaluate_module(module_id)
350
- return @mods.fetch(module_id) if @mods.key?(module_id)
356
+ if @mods.key?(module_id)
357
+ mod = @mods.fetch(module_id)
358
+ # A failed reload is remembered so later demand raises the stored
359
+ # error rather than serving stale exports.
360
+ raise mod.error if mod.is_a?(FailedModule)
361
+
362
+ return mod
363
+ end
351
364
 
352
365
  record = @records.fetch(module_id) { collect_module(module_id) }
353
366
  raise_failed_module!(record)
354
367
  evaluate_eager_dependencies(record.resolved_dependencies)
355
368
  dependency_records = dependency_records_for(eager_dependencies(record.resolved_dependencies))
356
369
  mod =
357
- Runtime::Mod.new(
358
- module_id.to_s,
359
- record.transformed_source,
360
- imports: imports_for(record.resolved_dependencies, dependency_records),
361
- source_map: record.source_map,
362
- version: record.version,
363
- eval_path: eval_path_for(module_id),
364
- namespace: namespace
365
- )
370
+ evaluating(module_id, record.transformed_source, record.source) do
371
+ Runtime::Mod.new(
372
+ module_id.to_s,
373
+ record.transformed_source,
374
+ imports: imports_for(record.resolved_dependencies, dependency_records),
375
+ source_map: record.source_map,
376
+ version: record.version,
377
+ eval_path: eval_path_for(module_id),
378
+ namespace: namespace
379
+ )
380
+ end
366
381
 
367
382
  @mods[module_id] = mod
368
383
  end
@@ -532,20 +547,50 @@ module Klenod
532
547
  raise UnsupportedFileError, "No plugin transformed #{module_id.path.inspect}. Add a plugin for #{module_id.extname.inspect} files."
533
548
  end
534
549
 
550
+ # A plugin that generates Ruby can generate Ruby that does not parse.
551
+ # Without this the failure waits until the module is evaluated, and a
552
+ # production build never evaluates application modules, so the bundle
553
+ # would ship broken. Ruby modules are already checked by RubyPlugin
554
+ # against their original source, which reports better locations.
555
+ def assert_generated_ruby_parses!(module_id, transform)
556
+ return if ruby_module_extension?(module_id.extname)
557
+ return if Prism.parse_success?(transform.code)
558
+
559
+ raise GeneratedRubyError.new(Prism.parse(transform.code), source: transform.code, module_id: module_id)
560
+ end
561
+
535
562
  def ruby_module_extension?(extname)
536
563
  extname.empty? || extname == ".rb"
537
564
  end
538
565
 
539
- def instantiate_module(module_id, transform, resolved_dependencies, dependency_records, cached)
540
- Runtime::Mod.new(
541
- module_id.to_s,
542
- transform.code,
543
- imports: imports_for(resolved_dependencies, dependency_records),
544
- source_map: transform.source_map,
545
- version: cached ? cached.version + 1 : 0,
546
- eval_path: eval_path_for(module_id),
547
- namespace: namespace
548
- )
566
+ def instantiate_module(module_id, transform, resolved_dependencies, dependency_records, cached, source)
567
+ evaluating(module_id, transform.code, source) do
568
+ Runtime::Mod.new(
569
+ module_id.to_s,
570
+ transform.code,
571
+ imports: imports_for(resolved_dependencies, dependency_records),
572
+ source_map: transform.source_map,
573
+ version: cached ? cached.version + 1 : 0,
574
+ eval_path: eval_path_for(module_id),
575
+ namespace: namespace
576
+ )
577
+ end
578
+ end
579
+
580
+ # Evaluating a module runs its Ruby, and a syntax error there is a
581
+ # ScriptError that escapes every `rescue => e` in the build. Only a syntax
582
+ # error is wrapped: a LoadError from the module's own `require` is also a
583
+ # ScriptError, and reporting that as a syntax error would be wrong.
584
+ #
585
+ # The reported line refers to the evaluated source, so the excerpt has to
586
+ # come from that -- except for a Ruby module, whose transform only
587
+ # rewrites import calls in place and leaves every line where it was. There
588
+ # the original source reads better.
589
+ def evaluating(module_id, evaluated_source, original_source)
590
+ yield
591
+ rescue ::SyntaxError => error
592
+ excerpt_source = ruby_module_extension?(module_id.extname) ? original_source : evaluated_source
593
+ raise GeneratedRubyError.new(error, source: excerpt_source || evaluated_source, module_id: module_id)
549
594
  end
550
595
 
551
596
  def eval_path_for(module_id)
@@ -670,7 +715,7 @@ module Klenod
670
715
 
671
716
  tasks.each_with_object({}) do |(dependency_id, child_task), records|
672
717
  records[dependency_id] = AsyncResult.unwrap(child_task.wait)
673
- rescue => e
718
+ rescue StandardError, ScriptError => e
674
719
  first_error ||= e
675
720
  end.tap do
676
721
  raise first_error if first_error
@@ -38,6 +38,23 @@ module Klenod
38
38
  :asset_updates,
39
39
  :errors
40
40
  ) do
41
+ # An invalidation that blew up before it could inspect any module, so
42
+ # subscribers still get told about the failure instead of the build
43
+ # going quiet.
44
+ def self.failed(error, module_id: nil)
45
+ new(
46
+ changed_module_ids: [].freeze,
47
+ removed_module_ids: [].freeze,
48
+ reloaded_module_ids: [].freeze,
49
+ reevaluated_module_ids: [].freeze,
50
+ added_assets: [].freeze,
51
+ changed_assets: [].freeze,
52
+ removed_assets: [].freeze,
53
+ asset_updates: [].freeze,
54
+ errors: [[module_id, error]].freeze
55
+ )
56
+ end
57
+
41
58
  def asset_changes
42
59
  AssetChanges.new(added_assets, changed_assets, removed_assets)
43
60
  end
@@ -5,6 +5,7 @@ require "toml-rb"
5
5
  require "yaml"
6
6
 
7
7
  require_relative "../plugin"
8
+ require_relative "../source_error"
8
9
  require_relative "../transform_result"
9
10
 
10
11
  module Klenod
@@ -16,14 +17,24 @@ module Klenod
16
17
  end
17
18
 
18
19
  class Plugin < Klenod::Build::Plugin
20
+ # A format that cannot fail to parse declares no wrapped errors, and
21
+ # `rescue *[]` then catches nothing.
22
+ WRAPPED_ERRORS = [].freeze
23
+
19
24
  def self.extensions(*values)
20
25
  const_set(:EXTENSIONS, values.freeze)
21
26
  end
22
27
 
28
+ # Names the SourceError to raise and the library exceptions it wraps.
29
+ def self.parse_error(error_class, *wrapped)
30
+ const_set(:PARSE_ERROR, error_class)
31
+ const_set(:WRAPPED_ERRORS, wrapped.freeze)
32
+ end
33
+
23
34
  def transform(module_id, code, _context)
24
35
  return super unless self.class::EXTENSIONS.include?(module_id.extname)
25
36
 
26
- data = parse(code)
37
+ data = parse_source(code, module_id)
27
38
  TransformResult.new(module_source(data), [], nil, [], [], {data: data})
28
39
  end
29
40
 
@@ -41,6 +52,15 @@ module Klenod
41
52
 
42
53
  private
43
54
 
55
+ # Each format's library raises its own exception type. Wrapping them
56
+ # here keeps every `parse` hook a one-liner and gives all four formats
57
+ # the same report.
58
+ def parse_source(code, module_id)
59
+ parse(code)
60
+ rescue *self.class::WRAPPED_ERRORS => error
61
+ raise self.class::PARSE_ERROR.new(error, source: code, module_id: module_id)
62
+ end
63
+
44
64
  def module_source(data)
45
65
  <<~RUBY
46
66
  Default = #{data.inspect}
@@ -54,8 +74,32 @@ module Klenod
54
74
  Plugin.new(...)
55
75
  end
56
76
 
77
+ class ParseError < Klenod::Build::SourceError
78
+ # "expected ':' after object key, got: '2' at line 3 column 7"
79
+ LOCATION = /\s+at line \d+ column \d+\z/
80
+
81
+ def kind
82
+ "JSON parse error"
83
+ end
84
+
85
+ private
86
+
87
+ def location(error)
88
+ return nil unless error.line
89
+
90
+ # The location is rendered as the title and the caret, so drop the
91
+ # copy the parser appends to its message.
92
+ Location.new(
93
+ line: error.line,
94
+ column: error.column,
95
+ detail: error.message.sub(LOCATION, "")
96
+ )
97
+ end
98
+ end
99
+
57
100
  class Plugin < DataPlugin::Plugin
58
101
  extensions ".json"
102
+ parse_error ParseError, JSON::ParserError
59
103
 
60
104
  private
61
105
 
@@ -70,8 +114,32 @@ module Klenod
70
114
  Plugin.new(...)
71
115
  end
72
116
 
117
+ class ParseError < Klenod::Build::SourceError
118
+ def kind
119
+ "YAML parse error"
120
+ end
121
+
122
+ private
123
+
124
+ def location(error)
125
+ # Psych::DisallowedClass and friends carry no location.
126
+ return nil unless error.is_a?(Psych::SyntaxError)
127
+
128
+ # One-based. libyaml points at the construct it was parsing when it
129
+ # gave up, which is not always the offending line, so `context` is
130
+ # worth surfacing.
131
+ Location.new(
132
+ line: error.line,
133
+ column: error.column,
134
+ detail: error.problem,
135
+ hints: [error.context&.capitalize].compact
136
+ )
137
+ end
138
+ end
139
+
73
140
  class Plugin < DataPlugin::Plugin
74
141
  extensions ".yaml", ".yml"
142
+ parse_error ParseError, Psych::Exception
75
143
 
76
144
  private
77
145
 
@@ -86,8 +154,34 @@ module Klenod
86
154
  Plugin.new(...)
87
155
  end
88
156
 
157
+ class ParseError < Klenod::Build::SourceError
158
+ # "Failed to parse input on line 2 at offset 9\ninvalid =\n\n ^"
159
+ LOCATION = /\AFailed to parse input on line (?<line>\d+) at offset (?<column>\d+)/
160
+
161
+ def kind
162
+ "TOML parse error"
163
+ end
164
+
165
+ private
166
+
167
+ def location(error)
168
+ found = LOCATION.match(error.message)
169
+ return nil unless found
170
+
171
+ # toml-rb embeds its own caret diagram in the message; we render our
172
+ # own from the line and column instead.
173
+ Location.new(
174
+ line: found[:line].to_i,
175
+ # citrus reports a zero-based offset into the line.
176
+ column: found[:column].to_i + 1,
177
+ detail: "Could not parse TOML"
178
+ )
179
+ end
180
+ end
181
+
89
182
  class Plugin < DataPlugin::Plugin
90
183
  extensions ".toml"
184
+ parse_error ParseError, TomlRB::Error
91
185
 
92
186
  private
93
187
 
@@ -1,27 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "klenod/runtime/source_map"
4
+ require_relative "../../source_error"
4
5
 
5
6
  module Klenod
6
7
  module Build
7
8
  module Plugins
8
9
  module HamlPlugin
9
- class ParseError < StandardError
10
- attr_reader :module_id, :source, :line, :column, :cause
11
-
12
- def initialize(error, source:, module_id:)
13
- @cause = error
14
- @module_id = module_id
15
- @source = source
16
- @line = source_line_for(error)
17
- @column = nil
18
-
19
- super(message_for(error))
20
- set_backtrace(error.backtrace)
10
+ class ParseError < Klenod::Build::SourceError
11
+ def kind
12
+ "Haml parse error"
21
13
  end
22
14
 
23
15
  private
24
16
 
17
+ def location(error)
18
+ detail, *sections = error.message.split(/\n\n+/)
19
+
20
+ Location.new(line: source_line_for(error), detail: detail.to_s, hints: hints_from(sections))
21
+ end
22
+
23
+ # A RubyParseError explains itself in trailing "Errors:"/"Missing:"
24
+ # sections, each an indented list of what to fix.
25
+ def hints_from(sections)
26
+ sections.flat_map do |section|
27
+ _heading, *lines = section.lines.map(&:chomp)
28
+ lines.map(&:strip)
29
+ end
30
+ end
31
+
25
32
  def source_line_for(error)
26
33
  line = error.line if error.respond_to?(:line)
27
34
  line ||= full_message_line_for(error)
@@ -40,50 +47,6 @@ module Klenod
40
47
  def error_line_zero_based?(error)
41
48
  error.respond_to?(:line) && error.line.is_a?(Integer) && !error.is_a?(RubyParseError)
42
49
  end
43
-
44
- def message_for(error)
45
- location =
46
- if module_id && line
47
- "#{module_id}:#{line}"
48
- elsif module_id
49
- module_id.to_s
50
- elsif line
51
- "line #{line}"
52
- end
53
-
54
- title = location ? "#{location}: Haml parse error" : "Haml parse error"
55
-
56
- [
57
- title,
58
- error.message,
59
- source_excerpt
60
- ].compact.join("\n\n")
61
- end
62
-
63
- def source_excerpt
64
- return nil unless line
65
-
66
- lines = source.lines
67
- return nil if lines.empty?
68
-
69
- index = line - 1
70
- first = [index - 2, 0].max
71
- last = [index + 2, lines.length - 1].min
72
- width = (last + 1).to_s.length
73
- excerpt =
74
- (first..last).map do |line_index|
75
- marker = (line_index == index) ? ">" : " "
76
- number = (line_index + 1).to_s.rjust(width)
77
- formatted = "#{marker} #{number} | #{lines.fetch(line_index).chomp}"
78
- if marker == ">"
79
- "\e[1;31m#{formatted}\e[0m"
80
- else
81
- formatted
82
- end
83
- end
84
-
85
- "Source:\n#{excerpt.join("\n")}"
86
- end
87
50
  end
88
51
 
89
52
  class RubyParseError < StandardError
@@ -10,6 +10,7 @@ require_relative "../dependency"
10
10
  require_relative "../hashing"
11
11
  require_relative "../load_result"
12
12
  require_relative "../plugin"
13
+ require_relative "../source_error"
13
14
  require_relative "../transform_result"
14
15
  require_relative "asset_javascript_metadata"
15
16
 
@@ -21,6 +22,46 @@ module Klenod
21
22
  Plugin.new(...)
22
23
  end
23
24
 
25
+ # An image that cannot be read. There is no line to point at, so the
26
+ # report is the file, what went wrong, and -- when the bytes turn out to
27
+ # be a different format than the extension claims -- what to rename it
28
+ # to.
29
+ class DecodeError < Klenod::Build::SourceError
30
+ # image_size names the format it recognised in its own message, e.g.
31
+ # "EOF in JPEG".
32
+ DETECTED_FORMAT = /\b(?<format>JPEG|PNG|GIF|WEBP|AVIF|HEIC|BMP|TIFF|SVG)\b/i
33
+
34
+ def initialize(error, module_id:, source: nil)
35
+ super(error, source: source, module_id: module_id)
36
+ end
37
+
38
+ def kind
39
+ "Image decode error"
40
+ end
41
+
42
+ private
43
+
44
+ def location(error)
45
+ detail = message_for(error)
46
+
47
+ Location.new(detail: detail, hints: [hint_for(detail)])
48
+ end
49
+
50
+ # When the bytes turn out to be a format the extension does not claim,
51
+ # renaming the file is the fix.
52
+ def hint_for(detail)
53
+ extname = module_id.extname.delete_prefix(".").downcase
54
+ detected = DETECTED_FORMAT.match(detail) { it[:format].downcase }
55
+
56
+ if detected && detected != extname && !(detected == "jpeg" && extname == "jpg")
57
+ basename = File.basename(module_id.path, module_id.extname)
58
+ "The file contains #{detected.upcase} data. Did you mean #{basename}.#{detected}?"
59
+ else
60
+ "The file is not a valid #{extname.upcase} image. It may be truncated or corrupt."
61
+ end
62
+ end
63
+ end
64
+
24
65
  class Plugin < Klenod::Build::Plugin
25
66
  EXTENSIONS = [".avif", ".gif", ".jpeg", ".jpg", ".png", ".webp"].freeze
26
67
  IMAGE_RUNTIME_SPECIFIER = "virtual:klenod/image"
@@ -51,11 +92,11 @@ module Klenod
51
92
 
52
93
  source_path = context.absolute_path(module_id)
53
94
  source_hash = Hashing.file_hexdigest(source_path)
54
- dimensions = image_dimensions(source_path)
95
+ dimensions = image_dimensions(source_path, module_id)
55
96
  image_options = image_options_for(module_id)
56
97
  asset = default_image_asset(module_id, source_path, source_hash, dimensions, image_options, context.asset_generation_queue)
57
98
  variant_assets = generate_variant_assets(module_id, source_path, source_hash, dimensions, image_options, context.asset_generation_queue)
58
- placeholder = image_placeholder(source_path, source_hash)
99
+ placeholder = image_placeholder(source_path, source_hash, module_id)
59
100
  [asset, *variant_assets].each { |image_asset| image_asset.url = context.asset_url(image_asset.output_path) }
60
101
  javascript_asset = javascript_image_asset(module_id, asset, variant_assets, context, placeholder:)
61
102
  javascript_asset.url = context.asset_url(javascript_asset.output_path)
@@ -101,11 +142,20 @@ module Klenod
101
142
 
102
143
  Dimensions = Data.define(:width, :height, :format)
103
144
 
104
- def image_dimensions(path)
145
+ # A file whose bytes are not an image at all used to pass silently with
146
+ # nil dimensions, and only failed later -- inside the asset generation
147
+ # queue -- if a variant happened to be requested.
148
+ def image_dimensions(path, module_id)
105
149
  size = ImageSize.path(path)
150
+ # A file that is not an image at all reports no format rather than
151
+ # raising, which used to pass silently with nil dimensions and only
152
+ # fail later, inside the asset generation queue, if a variant
153
+ # happened to be requested.
154
+ raise DecodeError.new("Could not read the image", module_id: module_id) if size.format.nil?
155
+
106
156
  Dimensions.new(size.width, size.height, size.format)
107
- rescue ImageSize::FormatError
108
- Dimensions.new(nil, nil, nil)
157
+ rescue ImageSize::FormatError => error
158
+ raise DecodeError.new(error, module_id: module_id)
109
159
  end
110
160
 
111
161
  def default_image_asset(module_id, source_path, source_hash, dimensions, image_options, queue)
@@ -165,11 +215,11 @@ module Klenod
165
215
  source_path,
166
216
  content_type(extname),
167
217
  metadata,
168
- writer: ->(io) { write_image_bytes(source_path, format, quality, io) },
218
+ writer: ->(io) { write_image_bytes(module_id, source_path, format, quality, io) },
169
219
  queue: queue,
170
220
  queue_kind: :cpu
171
221
  ) do
172
- generate_image_bytes(source_path, format, quality:)
222
+ generate_image_bytes(module_id, source_path, format, quality:)
173
223
  end
174
224
  end
175
225
 
@@ -262,12 +312,12 @@ module Klenod
262
312
  RUBY
263
313
  end
264
314
 
265
- def image_placeholder(source_path, source_hash)
315
+ def image_placeholder(source_path, source_hash, module_id)
266
316
  return nil unless @placeholder
267
317
 
268
318
  key = ImagePlaceholderKey.new(source_path.to_s, source_hash, @placeholder.width, @placeholder.format, @placeholder.quality)
269
319
  @placeholder_cache[key] ||= begin
270
- bytes = generate_image_bytes(source_path, @placeholder.format, width: @placeholder.width, quality: @placeholder.quality)
320
+ bytes = generate_image_bytes(module_id, source_path, @placeholder.format, width: @placeholder.width, quality: @placeholder.quality)
271
321
  "data:image/#{@placeholder.format};base64,#{Base64.strict_encode64(bytes)}"
272
322
  end
273
323
  end
@@ -393,16 +443,16 @@ module Klenod
393
443
  source_path,
394
444
  content_type(extname),
395
445
  metadata,
396
- writer: ->(io) { write_variant_bytes(source_path, width, format, quality, io) },
446
+ writer: ->(io) { write_variant_bytes(module_id, source_path, width, format, quality, io) },
397
447
  queue: queue,
398
448
  queue_kind: :cpu
399
449
  ) do
400
- generate_image_bytes(source_path, format, width: width, quality:)
450
+ generate_image_bytes(module_id, source_path, format, width: width, quality:)
401
451
  end
402
452
  end
403
453
 
404
- def generate_image_bytes(source_path, format, width: nil, quality: nil)
405
- image = Magick::Image.read(source_path.to_s).first
454
+ def generate_image_bytes(module_id, source_path, format, width: nil, quality: nil)
455
+ image = read_image(module_id, source_path)
406
456
  output_image = width ? image.resize_to_fit(width) : image
407
457
  output_image.to_blob do |info|
408
458
  info.format = format.upcase
@@ -413,12 +463,24 @@ module Klenod
413
463
  image&.destroy!
414
464
  end
415
465
 
416
- def write_variant_bytes(source_path, width, format, quality, io)
417
- io.write(generate_image_bytes(source_path, format, width: width, quality:))
466
+ # ImageMagick raises for a corrupt file and returns nothing at all for
467
+ # one it cannot identify. Both run inside the asset generation queue,
468
+ # where an unhandled failure says nothing about which import caused it.
469
+ def read_image(module_id, source_path)
470
+ image = Magick::Image.read(source_path.to_s).first
471
+ return image if image
472
+
473
+ raise DecodeError.new("ImageMagick could not identify the image", module_id: module_id)
474
+ rescue Magick::ImageMagickError => error
475
+ raise DecodeError.new(error, module_id: module_id)
476
+ end
477
+
478
+ def write_variant_bytes(module_id, source_path, width, format, quality, io)
479
+ io.write(generate_image_bytes(module_id, source_path, format, width: width, quality:))
418
480
  end
419
481
 
420
- def write_image_bytes(source_path, format, quality, io)
421
- io.write(generate_image_bytes(source_path, format, quality:))
482
+ def write_image_bytes(module_id, source_path, format, quality, io)
483
+ io.write(generate_image_bytes(module_id, source_path, format, quality:))
422
484
  end
423
485
 
424
486
  def scaled_height(dimensions, width)
@@ -3,6 +3,8 @@
3
3
  require "toml-rb"
4
4
 
5
5
  require_relative "../plugin"
6
+ require_relative "../source_error"
7
+ require_relative "data_plugin"
6
8
 
7
9
  module Klenod
8
10
  module Build
@@ -12,6 +14,15 @@ module Klenod
12
14
  Plugin.new(...)
13
15
  end
14
16
 
17
+ # Translations are TOML, so the report is the TOML one. It names the
18
+ # companion file rather than the component that imports it, because that
19
+ # is the file the developer has to fix.
20
+ class ParseError < TomlPlugin::ParseError
21
+ def kind
22
+ "Intl parse error"
23
+ end
24
+ end
25
+
15
26
  class Plugin < Klenod::Build::Plugin
16
27
  INTL_FILE_RE = /\.intl\.(?<locale>[^\/]+)\.toml\z/
17
28
 
@@ -24,9 +35,30 @@ module Klenod
24
35
  .sort
25
36
  .to_h do |path|
26
37
  locale = File.basename(path).match(INTL_FILE_RE)[:locale]
27
- [locale, TomlRB.load_file(path)]
38
+ [locale, translations_from(path, module_id)]
28
39
  end
29
40
  end
41
+
42
+ private
43
+
44
+ # Read the file here rather than using TomlRB.load_file, so a parse
45
+ # failure can carry the source for the excerpt.
46
+ def translations_from(path, module_id)
47
+ source = File.read(path)
48
+
49
+ begin
50
+ TomlRB.parse(source)
51
+ rescue TomlRB::Error => error
52
+ raise ParseError.new(error, source: source, module_id: companion_id(path, module_id))
53
+ end
54
+ end
55
+
56
+ # The companion is not a module in the graph, but its id resolves to a
57
+ # path for display just the same.
58
+ def companion_id(path, module_id)
59
+ directory = File.dirname(module_id.path)
60
+ ModuleId.new(File.join(directory, File.basename(path)), nil)
61
+ end
30
62
  end
31
63
  end
32
64
  end
@@ -7,10 +7,12 @@ require "yaml"
7
7
  require_relative "../dependency"
8
8
  require_relative "../module_id"
9
9
  require_relative "../plugin"
10
+ require_relative "../source_error"
10
11
  require_relative "../transform_result"
11
12
  require_relative "../watched_pattern"
12
13
  require_relative "class_names_runtime"
13
14
  require_relative "component_defaults"
15
+ require_relative "data_plugin"
14
16
  require_relative "haml_plugin"
15
17
  require_relative "markdown_compiler"
16
18
 
@@ -22,6 +24,30 @@ module Klenod
22
24
  Plugin.new(...)
23
25
  end
24
26
 
27
+ # Frontmatter is YAML, so the report is the YAML one, shifted to file
28
+ # lines. Kramdown does not fail on malformed Markdown -- it collects
29
+ # warnings -- so frontmatter is the only parse failure a .md file has.
30
+ class FrontmatterError < YamlPlugin::ParseError
31
+ def initialize(error, source:, module_id:, line_offset: 0)
32
+ @line_offset = line_offset
33
+
34
+ super(error, source: source, module_id: module_id)
35
+ end
36
+
37
+ def kind
38
+ "Markdown frontmatter error"
39
+ end
40
+
41
+ private
42
+
43
+ def location(error)
44
+ found = super
45
+ return found unless found&.line
46
+
47
+ found.with(line: found.line + @line_offset)
48
+ end
49
+ end
50
+
25
51
  class Plugin < Klenod::Build::Plugin
26
52
  include ClassNamesRuntime
27
53
 
@@ -49,7 +75,7 @@ module Klenod
49
75
  def transform(module_id, code, context)
50
76
  return super unless module_id.extname == ".md"
51
77
 
52
- frontmatter, markdown_source = parse_frontmatter(code)
78
+ frontmatter, markdown_source = parse_frontmatter(code, module_id)
53
79
  builder = HamlPlugin::Transformer::RubyBuilder.new(profiler: context.profiler)
54
80
  dependency = markdown_components_dependency(module_id, context)
55
81
  class_names_dependency = class_names_runtime_dependency(module_id)
@@ -96,19 +122,31 @@ module Klenod
96
122
 
97
123
  private
98
124
 
99
- def parse_frontmatter(source)
125
+ def parse_frontmatter(source, module_id)
100
126
  match = source.match(/\A---[ \t]*\r?\n(?<frontmatter>.*?\r?\n)---[ \t]*(?:\r?\n|\z)/m)
101
127
  return [{}, source] unless match
102
128
 
103
- frontmatter = YAML.safe_load(match[:frontmatter], permitted_classes: [Date, Time, Symbol], aliases: false, symbolize_names: true) || {}
129
+ frontmatter = load_frontmatter(match[:frontmatter], source, module_id) || {}
104
130
 
105
131
  unless frontmatter.is_a?(Hash)
106
- raise ArgumentError, "Markdown frontmatter must be a mapping"
132
+ raise FrontmatterError.new(
133
+ ArgumentError.new("Markdown frontmatter must be a mapping"),
134
+ source: source,
135
+ module_id: module_id
136
+ )
107
137
  end
108
138
 
109
139
  [normalize_frontmatter(frontmatter), source.byteslice(match.end(0)..) || ""]
110
140
  end
111
141
 
142
+ # Psych reports lines within the frontmatter slice, which starts after
143
+ # the opening "---", so shift them to match the file.
144
+ def load_frontmatter(frontmatter, source, module_id)
145
+ YAML.safe_load(frontmatter, permitted_classes: [Date, Time, Symbol], aliases: false, symbolize_names: true)
146
+ rescue Psych::Exception => error
147
+ raise FrontmatterError.new(error, source: source, module_id: module_id, line_offset: 1)
148
+ end
149
+
112
150
  def normalize_frontmatter(value)
113
151
  case value
114
152
  when Hash
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  require_relative "../plugin"
4
6
  require_relative "../ruby_import_rewriter"
7
+ require_relative "../source_error"
5
8
  require_relative "../transform_result"
6
9
 
7
10
  module Klenod
@@ -12,21 +15,75 @@ module Klenod
12
15
  Plugin.new(...)
13
16
  end
14
17
 
18
+ class ParseError < Klenod::Build::SourceError
19
+ def kind
20
+ "Ruby parse error"
21
+ end
22
+
23
+ private
24
+
25
+ def location(error)
26
+ return prism_location(error) if error.is_a?(Prism::ParseResult)
27
+ return nil unless error.respond_to?(:lineno)
28
+
29
+ Location.new(
30
+ line: error.lineno,
31
+ # SyntaxTree reports a zero-based column.
32
+ column: error.column && error.column + 1,
33
+ detail: error.message
34
+ )
35
+ end
36
+
37
+ # Prism reports each failure as data rather than a message to scrape,
38
+ # and phrases the first as "what is wrong; what was expected".
39
+ def prism_location(result)
40
+ first, *rest = result.errors
41
+ detail, _, expected = first.message.partition("; ")
42
+
43
+ Location.new(
44
+ line: first.location.start_line,
45
+ column: first.location.start_column + 1,
46
+ detail: detail,
47
+ hints: [expected, *rest.map(&:message)].reject(&:empty?).map(&:capitalize)
48
+ )
49
+ end
50
+ end
51
+
15
52
  class Plugin < Klenod::Build::Plugin
16
53
  def transform(module_id, code, context)
17
54
  return TransformResult.identity(code) unless module_id.extname == ".rb"
18
55
 
56
+ assert_parses!(module_id, code)
57
+
19
58
  result =
20
- RubyImportRewriter
21
- .new(
22
- module_id: module_id,
23
- kind: :ruby_import,
24
- source_dir: context.source_dir,
25
- profiler: context.profiler
26
- )
27
- .rewrite(code)
59
+ begin
60
+ RubyImportRewriter
61
+ .new(
62
+ module_id: module_id,
63
+ kind: :ruby_import,
64
+ source_dir: context.source_dir,
65
+ profiler: context.profiler
66
+ )
67
+ .rewrite(code)
68
+ rescue SyntaxTree::Parser::ParseError => error
69
+ raise ParseError.new(error, source: code, module_id: module_id)
70
+ end
28
71
  TransformResult.new(result.code, result.dependencies, nil, [], result.watched_patterns, {})
29
72
  end
73
+
74
+ private
75
+
76
+ # The rewriter only parses a file that contains an import it cannot
77
+ # rewrite literally, so without this check most syntax errors would
78
+ # not surface until the module was evaluated -- and a production build
79
+ # never evaluates application modules, so the bundle would ship
80
+ # broken. Prism is the parser CRuby itself uses, and validating costs
81
+ # well under a tenth of a millisecond per file.
82
+ def assert_parses!(module_id, code)
83
+ return if Prism.parse_success?(code)
84
+
85
+ raise ParseError.new(Prism.parse(code), source: code, module_id: module_id)
86
+ end
30
87
  end
31
88
  end
32
89
  end
@@ -6,6 +6,7 @@ require_relative "../errors"
6
6
  require_relative "../hashing"
7
7
  require_relative "../module_id"
8
8
  require_relative "../plugin"
9
+ require_relative "../source_error"
9
10
  require_relative "../transform_result"
10
11
  require_relative "asset_javascript_metadata"
11
12
 
@@ -17,6 +18,23 @@ module Klenod
17
18
  Plugin.new(...)
18
19
  end
19
20
 
21
+ # SVG markup is scraped with a regex rather than parsed, so malformed
22
+ # markup is tolerated. A file that is not text at all is not.
23
+ class EncodingError < Klenod::Build::SourceError
24
+ def kind
25
+ "SVG encoding error"
26
+ end
27
+
28
+ private
29
+
30
+ def location(error)
31
+ Location.new(
32
+ detail: error.message,
33
+ hints: ["An SVG file must be valid UTF-8 text."]
34
+ )
35
+ end
36
+ end
37
+
20
38
  class Plugin < Klenod::Build::Plugin
21
39
  EXTENSIONS = [".svg"].freeze
22
40
  SVG_RUNTIME_SPECIFIER = "virtual:klenod/svg"
@@ -39,7 +57,7 @@ module Klenod
39
57
 
40
58
  raise UnsupportedFileError, "SVG imports do not support query options: #{module_id}" if module_id.query
41
59
 
42
- dimensions = svg_dimensions(code)
60
+ dimensions = svg_dimensions(code, module_id)
43
61
  hash = Hashing.short(code)
44
62
  output_path = "/#{asset_name(module_id)}.#{hash}.svg"
45
63
  asset =
@@ -146,8 +164,8 @@ module Klenod
146
164
  JAVASCRIPT
147
165
  end
148
166
 
149
- def svg_dimensions(code)
150
- attributes = svg_attributes(code)
167
+ def svg_dimensions(code, module_id)
168
+ attributes = svg_attributes(code, module_id)
151
169
  return Dimensions.new(nil, nil) unless attributes
152
170
 
153
171
  explicit_width = parse_length(attributes["width"])
@@ -157,13 +175,18 @@ module Klenod
157
175
  view_box_dimensions(attributes["viewBox"] || attributes["viewbox"])
158
176
  end
159
177
 
160
- def svg_attributes(code)
178
+ # Malformed markup is tolerated: no <svg> match just means unknown
179
+ # dimensions. Only a file that is not text at all fails here, and
180
+ # String#match raises without saying which file it was reading.
181
+ def svg_attributes(code, module_id)
161
182
  match = code.match(/<svg(?=[\s>])(?<attributes>[^>]*)>/im)
162
183
  return nil unless match
163
184
 
164
185
  match[:attributes].scan(/([:\w.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/).to_h do |name, double_quoted, single_quoted, unquoted|
165
186
  [name, double_quoted || single_quoted || unquoted]
166
187
  end
188
+ rescue ArgumentError => error
189
+ raise EncodingError.new(error, source: "", module_id: module_id)
167
190
  end
168
191
 
169
192
  def view_box_dimensions(view_box)
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "errors"
6
+ require_relative "source_excerpt"
7
+
8
+ module Klenod
9
+ module Build
10
+ # Base class for a build failure attributable to one source file.
11
+ #
12
+ # Plugins parse very different formats with very different libraries, but the
13
+ # report a developer needs is the same every time: which file, which line and
14
+ # column, what went wrong, and what to try next. A subclass supplies `kind`
15
+ # and a `location` that unpacks its library's exception. Everything else --
16
+ # the message layout, the source excerpt, the synthesized backtrace frame --
17
+ # is shared, so every format renders identically in the terminal and in the
18
+ # browser error dialog.
19
+ #
20
+ # Being a StandardError matters: a bare Ruby SyntaxError is a ScriptError and
21
+ # escapes every `rescue => e` in the build, taking the watcher thread down
22
+ # with it.
23
+ class SourceError < Error
24
+ # `hints` is free-form advice, one entry per line, e.g.
25
+ # "Did you mean hero.jpeg?".
26
+ Location = Data.define(:line, :column, :detail, :hints) do
27
+ def initialize(line: nil, column: nil, detail: nil, hints: []) = super
28
+ end
29
+
30
+ attr_reader :module_id, :source, :line, :column, :cause, :detail, :hints
31
+
32
+ def initialize(error, source:, module_id:)
33
+ @cause = error
34
+ @module_id = module_id
35
+ @source = source
36
+
37
+ found = location_for(error)
38
+ @line = found.line
39
+ @column = found.column
40
+ @detail = found.detail || message_for(error)
41
+ @hints = Array(found.hints).map(&:to_s).reject(&:empty?).freeze
42
+
43
+ super(
44
+ SourceExcerpt.message(
45
+ module_id:,
46
+ line: @line,
47
+ column: @column,
48
+ kind:,
49
+ source:,
50
+ message: @detail,
51
+ hints: @hints
52
+ )
53
+ )
54
+
55
+ set_backtrace(backtrace_for(error))
56
+ end
57
+
58
+ # "JavaScript parse error". Used as the label in the browser error dialog,
59
+ # so it names the format rather than the plugin.
60
+ def kind
61
+ "Parse error"
62
+ end
63
+
64
+ private
65
+
66
+ # Re-wrapping is idempotent: a plugin that catches its own SourceError to
67
+ # attach the source keeps the location the first construction worked out.
68
+ def location_for(error)
69
+ return location(error) || Location.new unless error.is_a?(SourceError)
70
+
71
+ Location.new(line: error.line, column: error.column, detail: error.detail, hints: error.hints)
72
+ end
73
+
74
+ # Subclasses override to pull line, column, detail and hints out of the
75
+ # exception their parsing library raised. Returning nil keeps the wrapped
76
+ # message as the detail and renders no excerpt.
77
+ def location(_error)
78
+ nil
79
+ end
80
+
81
+ # Prism reports each failure as data rather than a message to scrape, and
82
+ # phrases the first as "what is wrong; what was expected".
83
+ def prism_location(result)
84
+ first, *rest = result.errors
85
+ detail, _, expected = first.message.partition("; ")
86
+
87
+ Location.new(
88
+ line: first.location.start_line,
89
+ column: first.location.start_column + 1,
90
+ detail: detail,
91
+ hints: [expected, *rest.map(&:message)].reject(&:empty?).map(&:capitalize)
92
+ )
93
+ end
94
+
95
+ # A native extension can raise with a bare message rather than an
96
+ # exception, so do not assume #message exists.
97
+ def message_for(error)
98
+ error.respond_to?(:message) ? error.message : error.to_s
99
+ end
100
+
101
+ # The browser dialog highlights source lines by matching backtrace frames
102
+ # against the filename. A native parser, or one that raises while
103
+ # evaluating generated source, leaves no Ruby frame pointing at the
104
+ # module, so synthesize one.
105
+ def backtrace_for(error)
106
+ frames = error.respond_to?(:backtrace) ? Array(error.backtrace) : []
107
+ return frames unless line
108
+
109
+ ["#{module_id}:#{[line, column].compact.join(":")}", *frames]
110
+ end
111
+ end
112
+
113
+ # Ruby generated from another format that does not parse.
114
+ #
115
+ # This is a bug in the plugin that generated it rather than in anything the
116
+ # developer wrote, so the excerpt shows the generated source. It is checked
117
+ # while collecting, and again as a backstop when a module is evaluated: a
118
+ # bare SyntaxError there is a ScriptError, which escapes every `rescue => e`
119
+ # in the build and takes the watcher thread down with it.
120
+ class GeneratedRubyError < SourceError
121
+ # "app:/x.rb:3: syntax error found"
122
+ HEADER = /\A(?<file>.+?):(?<line>\d+): (?<detail>.+?)$/
123
+ # " | ^~~ unexpected 'end'; expected a `)` to close the arguments"
124
+ CARET = /^ *\| (?<pad> *)\^+~* *(?<message>.*)$/
125
+
126
+ def kind
127
+ "Generated Ruby syntax error"
128
+ end
129
+
130
+ private
131
+
132
+ # Prism renders its own excerpt into the message it puts on a SyntaxError.
133
+ # We re-render it from the line and column, and keep the explanation it
134
+ # prints beside the caret.
135
+ def location(error)
136
+ return prism_location(error) if error.is_a?(Prism::ParseResult)
137
+
138
+ header = HEADER.match(error.message)
139
+ return nil unless header
140
+
141
+ caret = CARET.match(error.message)
142
+ detail, _, hint = (caret ? caret[:message] : header[:detail]).partition("; ")
143
+
144
+ Location.new(
145
+ line: header[:line].to_i,
146
+ column: caret && caret[:pad].length + 1,
147
+ detail: detail,
148
+ hints: [hint.empty? ? nil : hint.capitalize].compact
149
+ )
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Build
5
+ # Shared formatting for build errors that can point at a line of source.
6
+ #
7
+ # Plugins raise errors carrying the original source and a location, and the
8
+ # resulting message is shown both in the terminal and in the browser error
9
+ # dialog, so it needs to read well as plain text. The browser passes
10
+ # `ansi: false` to get the same layout without escape codes.
11
+ module SourceExcerpt
12
+ MARKED_LINE = "\e[1;31m"
13
+ RESET = "\e[0m"
14
+
15
+ module_function
16
+
17
+ # "app:/pages/thing.tsx:33:9: JavaScript parse error"
18
+ def title(module_id:, line:, kind:, column: nil)
19
+ location =
20
+ if module_id && line
21
+ "#{module_id}:#{[line, column].compact.join(":")}"
22
+ elsif module_id
23
+ module_id.to_s
24
+ elsif line
25
+ column ? "line #{line} column #{column}" : "line #{line}"
26
+ end
27
+
28
+ location ? "#{location}: #{kind}" : kind
29
+ end
30
+
31
+ def message(module_id:, line:, kind:, source:, message:, column: nil, hints: [], context: 2, ansi: true)
32
+ [
33
+ title(module_id:, line:, column:, kind:),
34
+ message,
35
+ excerpt(source:, line:, column:, context:, ansi:),
36
+ hint_section(hints)
37
+ ].compact.join("\n\n")
38
+ end
39
+
40
+ def excerpt(source:, line:, column: nil, context: 2, ansi: true)
41
+ return nil unless line
42
+
43
+ lines = source.to_s.lines
44
+ return nil if lines.empty?
45
+
46
+ index = line - 1
47
+ return nil unless index.between?(0, lines.length - 1)
48
+
49
+ first = [index - context, 0].max
50
+ last = [index + context, lines.length - 1].min
51
+ width = (last + 1).to_s.length
52
+
53
+ rows =
54
+ (first..last).flat_map do |line_index|
55
+ marked = line_index == index
56
+ number = (line_index + 1).to_s.rjust(width)
57
+ formatted = "#{marked ? ">" : " "} #{number} | #{lines.fetch(line_index).chomp}"
58
+ formatted = "#{MARKED_LINE}#{formatted}#{RESET}" if marked && ansi
59
+
60
+ marked ? [formatted, caret_row(width, column)].compact : [formatted]
61
+ end
62
+
63
+ "Source:\n#{rows.join("\n")}"
64
+ end
65
+
66
+ # " | ^", aligned under the offending column of the marked line.
67
+ def caret_row(width, column)
68
+ return nil unless column&.positive?
69
+
70
+ "#{" " * (width + 3)}| #{" " * (column - 1)}^"
71
+ end
72
+
73
+ # What to try next. Rendered like the "Source:" section so the two read as
74
+ # one report.
75
+ def hint_section(hints)
76
+ hints = Array(hints).map(&:to_s).reject(&:empty?)
77
+ return nil if hints.empty?
78
+
79
+ heading = (hints.length == 1) ? "Hint:" : "Hints:"
80
+ "#{heading}\n#{hints.map { " #{it}" }.join("\n")}"
81
+ end
82
+ end
83
+ end
84
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Klenod
4
4
  module Build
5
- VERSION = "0.0.13"
5
+ VERSION = "0.0.14"
6
6
  end
7
7
  end
@@ -129,11 +129,20 @@ module Klenod
129
129
 
130
130
  def emit_update(changed_paths, removed_paths)
131
131
  @graph_version += 1
132
- result = @context.invalidate_paths(changed_paths, removed_paths: removed_paths)
132
+ result = invalidate(changed_paths, removed_paths)
133
133
 
134
134
  @context.emit_update(UpdateEvent.new(changed_paths, removed_paths, @graph_version, result))
135
135
  end
136
136
 
137
+ # Invalidation runs on the worker thread, so anything escaping here
138
+ # would kill it and take hot reloading down for the rest of the
139
+ # process. Report the failure as a result instead.
140
+ def invalidate(changed_paths, removed_paths)
141
+ @context.invalidate_paths(changed_paths, removed_paths: removed_paths)
142
+ rescue StandardError, ScriptError => e
143
+ InvalidationResult.failed(e)
144
+ end
145
+
137
146
  def monotonic_time
138
147
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
148
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: klenod-build
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.13
4
+ version: 0.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrés Alin
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.0.13
18
+ version: 0.0.14
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.0.13
25
+ version: 0.0.14
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: async
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -297,6 +297,8 @@ files:
297
297
  - lib/klenod/build/resolution_error_formatter.rb
298
298
  - lib/klenod/build/resolver.rb
299
299
  - lib/klenod/build/ruby_import_rewriter.rb
300
+ - lib/klenod/build/source_error.rb
301
+ - lib/klenod/build/source_excerpt.rb
300
302
  - lib/klenod/build/source_map.rb
301
303
  - lib/klenod/build/source_map/editor.rb
302
304
  - lib/klenod/build/source_map/map.rb