klenod-build 0.0.13 → 0.0.15
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/context.rb +8 -2
- data/lib/klenod/build/filesystem_resolver.rb +21 -6
- data/lib/klenod/build/graph/invalidator.rb +24 -10
- data/lib/klenod/build/graph.rb +168 -27
- data/lib/klenod/build/hashing.rb +7 -0
- data/lib/klenod/build/invalidation_result.rb +17 -0
- data/lib/klenod/build/plugins/data_plugin.rb +95 -1
- data/lib/klenod/build/plugins/gem_import_plugin.rb +2 -2
- data/lib/klenod/build/plugins/google_fonts_plugin.rb +19 -1
- data/lib/klenod/build/plugins/haml_plugin/errors.rb +19 -56
- data/lib/klenod/build/plugins/haml_plugin/transformer.rb +2 -0
- data/lib/klenod/build/plugins/haml_plugin.rb +4 -0
- data/lib/klenod/build/plugins/image_plugin.rb +113 -17
- data/lib/klenod/build/plugins/intl_plugin.rb +57 -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/static_asset_plugin.rb +8 -0
- data/lib/klenod/build/plugins/svg_plugin.rb +27 -4
- data/lib/klenod/build/resolver.rb +1 -0
- data/lib/klenod/build/ruby_import_rewriter.rb +18 -1
- 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
|
@@ -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)
|
|
@@ -39,6 +39,18 @@ module Klenod
|
|
|
39
39
|
IMPORT_SOURCE_PATTERN = /(?<![A-Za-z0-9_])(?:import|lazy_import|import_glob)\s*(?:\(|["'])/
|
|
40
40
|
FAST_LITERAL_IMPORT_PATTERN = /(?<![A-Za-z0-9_])(import|lazy_import)\s*\(\s*("(?:\\.|[^"\\#])*")\s*\)/
|
|
41
41
|
|
|
42
|
+
# Ruby that does not parse, reported at its line in the enclosing
|
|
43
|
+
# source. Haml wraps it into its own parse error with a source excerpt.
|
|
44
|
+
class ParseError < Klenod::Build::Error
|
|
45
|
+
attr_reader :line, :column
|
|
46
|
+
|
|
47
|
+
def initialize(message, line:, column:)
|
|
48
|
+
@line = line
|
|
49
|
+
@column = column
|
|
50
|
+
super(message)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
42
54
|
def initialize(module_id:, kind:, source_dir: nil, profiler: nil, dependency_id_offset: 0, source_line_offset: 0, source_column_offset: 0)
|
|
43
55
|
@module_id = module_id
|
|
44
56
|
@kind = kind
|
|
@@ -54,7 +66,12 @@ module Klenod
|
|
|
54
66
|
fast_result = rewrite_literal_import_calls(code)
|
|
55
67
|
return fast_result if fast_result
|
|
56
68
|
|
|
57
|
-
ast =
|
|
69
|
+
ast =
|
|
70
|
+
begin
|
|
71
|
+
measure(:ruby_import_parse) { SyntaxTree.parse(code) }
|
|
72
|
+
rescue SyntaxTree::Parser::ParseError => error
|
|
73
|
+
raise ParseError.new(error.message, line: error.lineno + @source_line_offset, column: error.column + 1 + @source_column_offset)
|
|
74
|
+
end
|
|
58
75
|
calls = measure(:ruby_import_scan) { import_calls(ast) }
|
|
59
76
|
expanded = expand_import_calls(calls)
|
|
60
77
|
|
|
@@ -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.15
|
|
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.15
|
|
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.15
|
|
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
|