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 +4 -4
- data/lib/klenod/build/graph/invalidator.rb +19 -5
- data/lib/klenod/build/graph.rb +68 -23
- data/lib/klenod/build/invalidation_result.rb +17 -0
- data/lib/klenod/build/plugins/data_plugin.rb +95 -1
- data/lib/klenod/build/plugins/haml_plugin/errors.rb +19 -56
- data/lib/klenod/build/plugins/image_plugin.rb +79 -17
- data/lib/klenod/build/plugins/intl_plugin.rb +33 -1
- data/lib/klenod/build/plugins/markdown_plugin.rb +42 -4
- data/lib/klenod/build/plugins/ruby_plugin.rb +65 -8
- data/lib/klenod/build/plugins/svg_plugin.rb +27 -4
- data/lib/klenod/build/source_error.rb +153 -0
- data/lib/klenod/build/source_excerpt.rb +84 -0
- data/lib/klenod/build/version.rb +1 -1
- data/lib/klenod/build/watcher.rb +10 -1
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af646580bbf44262cf5f2262b5fd81eb7a77eeb94498d7ae00e44383cc26ce42
|
|
4
|
+
data.tar.gz: 6c84bd8dcc4d42be762dabd59bcb918d3aa42418772bd54f68b55e5cd0f3d17d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/klenod/build/graph.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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 =
|
|
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 <
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
417
|
-
|
|
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,
|
|
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 =
|
|
129
|
+
frontmatter = load_frontmatter(match[:frontmatter], source, module_id) || {}
|
|
104
130
|
|
|
105
131
|
unless frontmatter.is_a?(Hash)
|
|
106
|
-
raise
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
data/lib/klenod/build/version.rb
CHANGED
data/lib/klenod/build/watcher.rb
CHANGED
|
@@ -129,11 +129,20 @@ module Klenod
|
|
|
129
129
|
|
|
130
130
|
def emit_update(changed_paths, removed_paths)
|
|
131
131
|
@graph_version += 1
|
|
132
|
-
result =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|