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
|
@@ -163,7 +163,9 @@ module Klenod
|
|
|
163
163
|
return nil unless google_fonts_module?(module_id)
|
|
164
164
|
|
|
165
165
|
url = google_fonts_url_for(module_id)
|
|
166
|
-
css = fetch_css(url)
|
|
166
|
+
css = context.analysis? ? cached_css(url) : fetch_css(url)
|
|
167
|
+
return analysis_load(module_id, url, context) unless css
|
|
168
|
+
|
|
167
169
|
font_faces = FontFaceParser.new(css).font_faces_by_url
|
|
168
170
|
font_assets = {}
|
|
169
171
|
rewritten_css =
|
|
@@ -220,6 +222,22 @@ module Klenod
|
|
|
220
222
|
raise Error, "Could not download Google Fonts asset #{url.inspect}: #{error.message}"
|
|
221
223
|
end
|
|
222
224
|
|
|
225
|
+
# Analysis never touches the network: a warm cache is used as is,
|
|
226
|
+
# and a cold one yields an empty stylesheet that keeps importers
|
|
227
|
+
# transforming.
|
|
228
|
+
def cached_css(url)
|
|
229
|
+
return nil unless @css_cache && !@refresh_cache
|
|
230
|
+
|
|
231
|
+
@css_cache.read(url)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def analysis_load(module_id, url, context)
|
|
235
|
+
css_asset = css_asset(module_id, url, "", [])
|
|
236
|
+
css_asset.url = context.asset_url(css_asset.output_path)
|
|
237
|
+
@assets_by_module_id[module_id] = [css_asset]
|
|
238
|
+
ruby_module_source(css_asset.url)
|
|
239
|
+
end
|
|
240
|
+
|
|
223
241
|
def fetch_css(url)
|
|
224
242
|
if @css_cache && !@refresh_cache
|
|
225
243
|
css = @css_cache.read(url)
|
|
@@ -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
|
|
@@ -136,6 +136,8 @@ module Klenod
|
|
|
136
136
|
end
|
|
137
137
|
rescue RubyParseError => error
|
|
138
138
|
raise ParseError.new(error, source: source, module_id: module_id)
|
|
139
|
+
rescue RubyImportRewriter::ParseError => error
|
|
140
|
+
raise ParseError.new(RubyParseError.new(error.message, line: error.line), source: source, module_id: module_id)
|
|
139
141
|
ensure
|
|
140
142
|
@profiler = previous_profiler
|
|
141
143
|
@module_id = previous_module_id
|
|
@@ -78,6 +78,10 @@ module Klenod
|
|
|
78
78
|
@transformer = Transformer.new
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# The validated `variables` mapping, so tooling such as the language
|
|
82
|
+
# server can tell whether `$name` reads a framework prop.
|
|
83
|
+
attr_reader :variables
|
|
84
|
+
|
|
81
85
|
def resolve(dependency, _context)
|
|
82
86
|
styles_dependency = resolve_class_names_runtime(dependency)
|
|
83
87
|
return styles_dependency if styles_dependency
|
|
@@ -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"
|
|
@@ -50,12 +91,14 @@ module Klenod
|
|
|
50
91
|
return nil unless EXTENSIONS.include?(module_id.extname)
|
|
51
92
|
|
|
52
93
|
source_path = context.absolute_path(module_id)
|
|
94
|
+
return analysis_load(module_id, source_path, context) if context.analysis?
|
|
95
|
+
|
|
53
96
|
source_hash = Hashing.file_hexdigest(source_path)
|
|
54
|
-
dimensions = image_dimensions(source_path)
|
|
97
|
+
dimensions = image_dimensions(source_path, module_id)
|
|
55
98
|
image_options = image_options_for(module_id)
|
|
56
99
|
asset = default_image_asset(module_id, source_path, source_hash, dimensions, image_options, context.asset_generation_queue)
|
|
57
100
|
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)
|
|
101
|
+
placeholder = image_placeholder(source_path, source_hash, module_id)
|
|
59
102
|
[asset, *variant_assets].each { |image_asset| image_asset.url = context.asset_url(image_asset.output_path) }
|
|
60
103
|
javascript_asset = javascript_image_asset(module_id, asset, variant_assets, context, placeholder:)
|
|
61
104
|
javascript_asset.url = context.asset_url(javascript_asset.output_path)
|
|
@@ -101,11 +144,52 @@ module Klenod
|
|
|
101
144
|
|
|
102
145
|
Dimensions = Data.define(:width, :height, :format)
|
|
103
146
|
|
|
104
|
-
|
|
147
|
+
# A file whose bytes are not an image at all used to pass silently with
|
|
148
|
+
# nil dimensions, and only failed later -- inside the asset generation
|
|
149
|
+
# queue -- if a variant happened to be requested.
|
|
150
|
+
def image_dimensions(path, module_id)
|
|
105
151
|
size = ImageSize.path(path)
|
|
152
|
+
# A file that is not an image at all reports no format rather than
|
|
153
|
+
# raising, which used to pass silently with nil dimensions and only
|
|
154
|
+
# fail later, inside the asset generation queue, if a variant
|
|
155
|
+
# happened to be requested.
|
|
156
|
+
raise DecodeError.new("Could not read the image", module_id: module_id) if size.format.nil?
|
|
157
|
+
|
|
106
158
|
Dimensions.new(size.width, size.height, size.format)
|
|
107
|
-
rescue ImageSize::FormatError
|
|
108
|
-
|
|
159
|
+
rescue ImageSize::FormatError => error
|
|
160
|
+
raise DecodeError.new(error, module_id: module_id)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Analysis keeps the module shape (dimensions, content type, a
|
|
164
|
+
# single asset reference) without hashing the file, decoding it, or
|
|
165
|
+
# describing variants nobody will generate.
|
|
166
|
+
def analysis_load(module_id, source_path, context)
|
|
167
|
+
source_hash = Hashing.file_key(source_path)
|
|
168
|
+
dimensions = image_dimensions(source_path, module_id)
|
|
169
|
+
asset = static_image_asset(module_id, source_path, source_hash, dimensions)
|
|
170
|
+
asset.url = context.asset_url(asset.output_path)
|
|
171
|
+
image_runtime_dependency = image_runtime_dependency_for(module_id)
|
|
172
|
+
transform =
|
|
173
|
+
TransformResult.new(
|
|
174
|
+
image_module_source(module_id, [asset], image_runtime_dependency, context, placeholder: nil),
|
|
175
|
+
[image_runtime_dependency],
|
|
176
|
+
nil,
|
|
177
|
+
[asset],
|
|
178
|
+
[],
|
|
179
|
+
{}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
LoadResult.new(image_source(module_id), source_hash, transform)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def image_runtime_dependency_for(module_id)
|
|
186
|
+
Dependency
|
|
187
|
+
.create(
|
|
188
|
+
specifier: IMAGE_RUNTIME_SPECIFIER,
|
|
189
|
+
importer_id: module_id,
|
|
190
|
+
kind: :image_runtime
|
|
191
|
+
)
|
|
192
|
+
.with(id: "#{module_id}:image_runtime")
|
|
109
193
|
end
|
|
110
194
|
|
|
111
195
|
def default_image_asset(module_id, source_path, source_hash, dimensions, image_options, queue)
|
|
@@ -165,11 +249,11 @@ module Klenod
|
|
|
165
249
|
source_path,
|
|
166
250
|
content_type(extname),
|
|
167
251
|
metadata,
|
|
168
|
-
writer: ->(io) { write_image_bytes(source_path, format, quality, io) },
|
|
252
|
+
writer: ->(io) { write_image_bytes(module_id, source_path, format, quality, io) },
|
|
169
253
|
queue: queue,
|
|
170
254
|
queue_kind: :cpu
|
|
171
255
|
) do
|
|
172
|
-
generate_image_bytes(source_path, format, quality:)
|
|
256
|
+
generate_image_bytes(module_id, source_path, format, quality:)
|
|
173
257
|
end
|
|
174
258
|
end
|
|
175
259
|
|
|
@@ -262,12 +346,12 @@ module Klenod
|
|
|
262
346
|
RUBY
|
|
263
347
|
end
|
|
264
348
|
|
|
265
|
-
def image_placeholder(source_path, source_hash)
|
|
349
|
+
def image_placeholder(source_path, source_hash, module_id)
|
|
266
350
|
return nil unless @placeholder
|
|
267
351
|
|
|
268
352
|
key = ImagePlaceholderKey.new(source_path.to_s, source_hash, @placeholder.width, @placeholder.format, @placeholder.quality)
|
|
269
353
|
@placeholder_cache[key] ||= begin
|
|
270
|
-
bytes = generate_image_bytes(source_path, @placeholder.format, width: @placeholder.width, quality: @placeholder.quality)
|
|
354
|
+
bytes = generate_image_bytes(module_id, source_path, @placeholder.format, width: @placeholder.width, quality: @placeholder.quality)
|
|
271
355
|
"data:image/#{@placeholder.format};base64,#{Base64.strict_encode64(bytes)}"
|
|
272
356
|
end
|
|
273
357
|
end
|
|
@@ -393,16 +477,16 @@ module Klenod
|
|
|
393
477
|
source_path,
|
|
394
478
|
content_type(extname),
|
|
395
479
|
metadata,
|
|
396
|
-
writer: ->(io) { write_variant_bytes(source_path, width, format, quality, io) },
|
|
480
|
+
writer: ->(io) { write_variant_bytes(module_id, source_path, width, format, quality, io) },
|
|
397
481
|
queue: queue,
|
|
398
482
|
queue_kind: :cpu
|
|
399
483
|
) do
|
|
400
|
-
generate_image_bytes(source_path, format, width: width, quality:)
|
|
484
|
+
generate_image_bytes(module_id, source_path, format, width: width, quality:)
|
|
401
485
|
end
|
|
402
486
|
end
|
|
403
487
|
|
|
404
|
-
def generate_image_bytes(source_path, format, width: nil, quality: nil)
|
|
405
|
-
image =
|
|
488
|
+
def generate_image_bytes(module_id, source_path, format, width: nil, quality: nil)
|
|
489
|
+
image = read_image(module_id, source_path)
|
|
406
490
|
output_image = width ? image.resize_to_fit(width) : image
|
|
407
491
|
output_image.to_blob do |info|
|
|
408
492
|
info.format = format.upcase
|
|
@@ -413,12 +497,24 @@ module Klenod
|
|
|
413
497
|
image&.destroy!
|
|
414
498
|
end
|
|
415
499
|
|
|
416
|
-
|
|
417
|
-
|
|
500
|
+
# ImageMagick raises for a corrupt file and returns nothing at all for
|
|
501
|
+
# one it cannot identify. Both run inside the asset generation queue,
|
|
502
|
+
# where an unhandled failure says nothing about which import caused it.
|
|
503
|
+
def read_image(module_id, source_path)
|
|
504
|
+
image = Magick::Image.read(source_path.to_s).first
|
|
505
|
+
return image if image
|
|
506
|
+
|
|
507
|
+
raise DecodeError.new("ImageMagick could not identify the image", module_id: module_id)
|
|
508
|
+
rescue Magick::ImageMagickError => error
|
|
509
|
+
raise DecodeError.new(error, module_id: module_id)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def write_variant_bytes(module_id, source_path, width, format, quality, io)
|
|
513
|
+
io.write(generate_image_bytes(module_id, source_path, format, width: width, quality:))
|
|
418
514
|
end
|
|
419
515
|
|
|
420
|
-
def write_image_bytes(source_path, format, quality, io)
|
|
421
|
-
io.write(generate_image_bytes(source_path, format, quality:))
|
|
516
|
+
def write_image_bytes(module_id, source_path, format, quality, io)
|
|
517
|
+
io.write(generate_image_bytes(module_id, source_path, format, quality:))
|
|
422
518
|
end
|
|
423
519
|
|
|
424
520
|
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,9 +14,24 @@ 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
|
|
|
29
|
+
CachedTranslations = Data.define(:mtime, :size, :translations)
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@cache = {}
|
|
33
|
+
end
|
|
34
|
+
|
|
18
35
|
def translations_for(context, module_id)
|
|
19
36
|
base = module_id.path.delete_suffix(module_id.extname)
|
|
20
37
|
pattern = context.absolute_path(ModuleId.new("#{base}.intl.*.toml", nil)).to_s
|
|
@@ -24,8 +41,47 @@ module Klenod
|
|
|
24
41
|
.sort
|
|
25
42
|
.to_h do |path|
|
|
26
43
|
locale = File.basename(path).match(INTL_FILE_RE)[:locale]
|
|
27
|
-
[locale,
|
|
44
|
+
[locale, translations_from(path, module_id)]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Changed or removed companions leave the cache immediately, so it
|
|
49
|
+
# only ever holds live files. Nothing else needs invalidating: the
|
|
50
|
+
# owning Haml module is re-transformed by HamlPlugin's own hook.
|
|
51
|
+
def invalidate_module_ids(paths, _context)
|
|
52
|
+
paths.each { |path| @cache.delete(File.expand_path(path)) }
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Parsed translations are kept per file and validated by stat, so a
|
|
59
|
+
# module that transforms often, as in the language server, does not
|
|
60
|
+
# re-read and re-parse companions that did not change. Read the file
|
|
61
|
+
# here rather than using TomlRB.load_file, so a parse failure can
|
|
62
|
+
# carry the source for the excerpt.
|
|
63
|
+
def translations_from(path, module_id)
|
|
64
|
+
stat = File.stat(path)
|
|
65
|
+
cached = @cache[path]
|
|
66
|
+
return cached.translations if cached && cached.mtime == stat.mtime && cached.size == stat.size
|
|
67
|
+
|
|
68
|
+
source = File.read(path)
|
|
69
|
+
translations =
|
|
70
|
+
begin
|
|
71
|
+
TomlRB.parse(source)
|
|
72
|
+
rescue TomlRB::Error => error
|
|
73
|
+
raise ParseError.new(error, source: source, module_id: companion_id(path, module_id))
|
|
28
74
|
end
|
|
75
|
+
|
|
76
|
+
@cache[path] = CachedTranslations.new(stat.mtime, stat.size, translations)
|
|
77
|
+
translations
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The companion is not a module in the graph, but its id resolves to a
|
|
81
|
+
# path for display just the same.
|
|
82
|
+
def companion_id(path, module_id)
|
|
83
|
+
directory = File.dirname(module_id.path)
|
|
84
|
+
ModuleId.new(File.join(directory, File.basename(path)), nil)
|
|
29
85
|
end
|
|
30
86
|
end
|
|
31
87
|
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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../asset"
|
|
4
4
|
require_relative "../hashing"
|
|
5
|
+
require_relative "../load_result"
|
|
5
6
|
require_relative "../plugin"
|
|
6
7
|
require_relative "../transform_result"
|
|
7
8
|
|
|
@@ -21,6 +22,13 @@ module Klenod
|
|
|
21
22
|
".woff2" => "font/woff2"
|
|
22
23
|
}.freeze
|
|
23
24
|
|
|
25
|
+
# Analysis never needs the bytes of a font, so the file is not read.
|
|
26
|
+
def load(module_id, context)
|
|
27
|
+
return nil unless context.analysis? && CONTENT_TYPES.key?(module_id.extname)
|
|
28
|
+
|
|
29
|
+
LoadResult.new("", Hashing.short(module_id.to_s), TransformResult.new("Default = nil\n", [], nil, [], [], {}))
|
|
30
|
+
end
|
|
31
|
+
|
|
24
32
|
def transform(module_id, code, context)
|
|
25
33
|
return super unless CONTENT_TYPES.key?(module_id.extname)
|
|
26
34
|
|