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.
@@ -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 < StandardError
10
- attr_reader :module_id, :source, :line, :column, :cause
11
-
12
- def initialize(error, source:, module_id:)
13
- @cause = error
14
- @module_id = module_id
15
- @source = source
16
- @line = source_line_for(error)
17
- @column = nil
18
-
19
- super(message_for(error))
20
- set_backtrace(error.backtrace)
10
+ class ParseError < Klenod::Build::SourceError
11
+ def kind
12
+ "Haml parse error"
21
13
  end
22
14
 
23
15
  private
24
16
 
17
+ def location(error)
18
+ detail, *sections = error.message.split(/\n\n+/)
19
+
20
+ Location.new(line: source_line_for(error), detail: detail.to_s, hints: hints_from(sections))
21
+ end
22
+
23
+ # A RubyParseError explains itself in trailing "Errors:"/"Missing:"
24
+ # sections, each an indented list of what to fix.
25
+ def hints_from(sections)
26
+ sections.flat_map do |section|
27
+ _heading, *lines = section.lines.map(&:chomp)
28
+ lines.map(&:strip)
29
+ end
30
+ end
31
+
25
32
  def source_line_for(error)
26
33
  line = error.line if error.respond_to?(:line)
27
34
  line ||= full_message_line_for(error)
@@ -40,50 +47,6 @@ module Klenod
40
47
  def error_line_zero_based?(error)
41
48
  error.respond_to?(:line) && error.line.is_a?(Integer) && !error.is_a?(RubyParseError)
42
49
  end
43
-
44
- def message_for(error)
45
- location =
46
- if module_id && line
47
- "#{module_id}:#{line}"
48
- elsif module_id
49
- module_id.to_s
50
- elsif line
51
- "line #{line}"
52
- end
53
-
54
- title = location ? "#{location}: Haml parse error" : "Haml parse error"
55
-
56
- [
57
- title,
58
- error.message,
59
- source_excerpt
60
- ].compact.join("\n\n")
61
- end
62
-
63
- def source_excerpt
64
- return nil unless line
65
-
66
- lines = source.lines
67
- return nil if lines.empty?
68
-
69
- index = line - 1
70
- first = [index - 2, 0].max
71
- last = [index + 2, lines.length - 1].min
72
- width = (last + 1).to_s.length
73
- excerpt =
74
- (first..last).map do |line_index|
75
- marker = (line_index == index) ? ">" : " "
76
- number = (line_index + 1).to_s.rjust(width)
77
- formatted = "#{marker} #{number} | #{lines.fetch(line_index).chomp}"
78
- if marker == ">"
79
- "\e[1;31m#{formatted}\e[0m"
80
- else
81
- formatted
82
- end
83
- end
84
-
85
- "Source:\n#{excerpt.join("\n")}"
86
- end
87
50
  end
88
51
 
89
52
  class RubyParseError < StandardError
@@ -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
- def image_dimensions(path)
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
- Dimensions.new(nil, nil, nil)
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 = Magick::Image.read(source_path.to_s).first
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
- def write_variant_bytes(source_path, width, format, quality, io)
417
- io.write(generate_image_bytes(source_path, format, width: width, quality:))
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, TomlRB.load_file(path)]
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 = YAML.safe_load(match[:frontmatter], permitted_classes: [Date, Time, Symbol], aliases: false, symbolize_names: true) || {}
129
+ frontmatter = load_frontmatter(match[:frontmatter], source, module_id) || {}
104
130
 
105
131
  unless frontmatter.is_a?(Hash)
106
- raise ArgumentError, "Markdown frontmatter must be a mapping"
132
+ raise FrontmatterError.new(
133
+ ArgumentError.new("Markdown frontmatter must be a mapping"),
134
+ source: source,
135
+ module_id: module_id
136
+ )
107
137
  end
108
138
 
109
139
  [normalize_frontmatter(frontmatter), source.byteslice(match.end(0)..) || ""]
110
140
  end
111
141
 
142
+ # Psych reports lines within the frontmatter slice, which starts after
143
+ # the opening "---", so shift them to match the file.
144
+ def load_frontmatter(frontmatter, source, module_id)
145
+ YAML.safe_load(frontmatter, permitted_classes: [Date, Time, Symbol], aliases: false, symbolize_names: true)
146
+ rescue Psych::Exception => error
147
+ raise FrontmatterError.new(error, source: source, module_id: module_id, line_offset: 1)
148
+ end
149
+
112
150
  def normalize_frontmatter(value)
113
151
  case value
114
152
  when Hash
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  require_relative "../plugin"
4
6
  require_relative "../ruby_import_rewriter"
7
+ require_relative "../source_error"
5
8
  require_relative "../transform_result"
6
9
 
7
10
  module Klenod
@@ -12,21 +15,75 @@ module Klenod
12
15
  Plugin.new(...)
13
16
  end
14
17
 
18
+ class ParseError < Klenod::Build::SourceError
19
+ def kind
20
+ "Ruby parse error"
21
+ end
22
+
23
+ private
24
+
25
+ def location(error)
26
+ return prism_location(error) if error.is_a?(Prism::ParseResult)
27
+ return nil unless error.respond_to?(:lineno)
28
+
29
+ Location.new(
30
+ line: error.lineno,
31
+ # SyntaxTree reports a zero-based column.
32
+ column: error.column && error.column + 1,
33
+ detail: error.message
34
+ )
35
+ end
36
+
37
+ # Prism reports each failure as data rather than a message to scrape,
38
+ # and phrases the first as "what is wrong; what was expected".
39
+ def prism_location(result)
40
+ first, *rest = result.errors
41
+ detail, _, expected = first.message.partition("; ")
42
+
43
+ Location.new(
44
+ line: first.location.start_line,
45
+ column: first.location.start_column + 1,
46
+ detail: detail,
47
+ hints: [expected, *rest.map(&:message)].reject(&:empty?).map(&:capitalize)
48
+ )
49
+ end
50
+ end
51
+
15
52
  class Plugin < Klenod::Build::Plugin
16
53
  def transform(module_id, code, context)
17
54
  return TransformResult.identity(code) unless module_id.extname == ".rb"
18
55
 
56
+ assert_parses!(module_id, code)
57
+
19
58
  result =
20
- RubyImportRewriter
21
- .new(
22
- module_id: module_id,
23
- kind: :ruby_import,
24
- source_dir: context.source_dir,
25
- profiler: context.profiler
26
- )
27
- .rewrite(code)
59
+ begin
60
+ RubyImportRewriter
61
+ .new(
62
+ module_id: module_id,
63
+ kind: :ruby_import,
64
+ source_dir: context.source_dir,
65
+ profiler: context.profiler
66
+ )
67
+ .rewrite(code)
68
+ rescue SyntaxTree::Parser::ParseError => error
69
+ raise ParseError.new(error, source: code, module_id: module_id)
70
+ end
28
71
  TransformResult.new(result.code, result.dependencies, nil, [], result.watched_patterns, {})
29
72
  end
73
+
74
+ private
75
+
76
+ # The rewriter only parses a file that contains an import it cannot
77
+ # rewrite literally, so without this check most syntax errors would
78
+ # not surface until the module was evaluated -- and a production build
79
+ # never evaluates application modules, so the bundle would ship
80
+ # broken. Prism is the parser CRuby itself uses, and validating costs
81
+ # well under a tenth of a millisecond per file.
82
+ def assert_parses!(module_id, code)
83
+ return if Prism.parse_success?(code)
84
+
85
+ raise ParseError.new(Prism.parse(code), source: code, module_id: module_id)
86
+ end
30
87
  end
31
88
  end
32
89
  end
@@ -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