klenod-build 0.0.1

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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +29 -0
  3. data/lib/klenod/build/asset.rb +235 -0
  4. data/lib/klenod/build/asset_compression.rb +58 -0
  5. data/lib/klenod/build/asset_generation_queue.rb +50 -0
  6. data/lib/klenod/build/cli/application.rb +108 -0
  7. data/lib/klenod/build/cli.rb +3 -0
  8. data/lib/klenod/build/config.rb +142 -0
  9. data/lib/klenod/build/context.rb +370 -0
  10. data/lib/klenod/build/dependency.rb +21 -0
  11. data/lib/klenod/build/errors.rb +175 -0
  12. data/lib/klenod/build/filesystem_resolver.rb +147 -0
  13. data/lib/klenod/build/graph/invalidator.rb +246 -0
  14. data/lib/klenod/build/graph.rb +864 -0
  15. data/lib/klenod/build/graphviz.rb +282 -0
  16. data/lib/klenod/build/hashing.rb +21 -0
  17. data/lib/klenod/build/invalidation_result.rb +55 -0
  18. data/lib/klenod/build/load_result.rb +7 -0
  19. data/lib/klenod/build/loaded_module.rb +38 -0
  20. data/lib/klenod/build/module_id.rb +100 -0
  21. data/lib/klenod/build/module_record.rb +22 -0
  22. data/lib/klenod/build/plugin.rb +35 -0
  23. data/lib/klenod/build/plugins/class_names_runtime.rb +125 -0
  24. data/lib/klenod/build/plugins/component_defaults.rb +12 -0
  25. data/lib/klenod/build/plugins/data_plugin.rb +117 -0
  26. data/lib/klenod/build/plugins/gem_import_plugin.rb +125 -0
  27. data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.json +17687 -0
  28. data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.rb +105 -0
  29. data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.txt +31 -0
  30. data/lib/klenod/build/plugins/google_fonts_plugin.rb +387 -0
  31. data/lib/klenod/build/plugins/haml_plugin/companions.rb +40 -0
  32. data/lib/klenod/build/plugins/haml_plugin/errors.rb +114 -0
  33. data/lib/klenod/build/plugins/haml_plugin/helper_source.rb +123 -0
  34. data/lib/klenod/build/plugins/haml_plugin/parser.rb +85 -0
  35. data/lib/klenod/build/plugins/haml_plugin/transformer/ruby_builder.rb +1039 -0
  36. data/lib/klenod/build/plugins/haml_plugin/transformer.rb +766 -0
  37. data/lib/klenod/build/plugins/haml_plugin.rb +369 -0
  38. data/lib/klenod/build/plugins/image_plugin.rb +401 -0
  39. data/lib/klenod/build/plugins/intl_plugin.rb +34 -0
  40. data/lib/klenod/build/plugins/markdown_compiler.rb +180 -0
  41. data/lib/klenod/build/plugins/markdown_plugin.rb +161 -0
  42. data/lib/klenod/build/plugins/router_plugin.rb +942 -0
  43. data/lib/klenod/build/plugins/ruby_plugin.rb +34 -0
  44. data/lib/klenod/build/plugins/svg_plugin.rb +197 -0
  45. data/lib/klenod/build/plugins.rb +22 -0
  46. data/lib/klenod/build/profiler.rb +79 -0
  47. data/lib/klenod/build/resolution_error_formatter.rb +42 -0
  48. data/lib/klenod/build/resolver.rb +95 -0
  49. data/lib/klenod/build/ruby_import_rewriter.rb +436 -0
  50. data/lib/klenod/build/source_map/editor.rb +110 -0
  51. data/lib/klenod/build/source_map/map.rb +168 -0
  52. data/lib/klenod/build/source_map/vlq.rb +68 -0
  53. data/lib/klenod/build/source_map.rb +12 -0
  54. data/lib/klenod/build/transform_result.rb +12 -0
  55. data/lib/klenod/build/version.rb +7 -0
  56. data/lib/klenod/build/watched_pattern.rb +11 -0
  57. data/lib/klenod/build/watcher.rb +141 -0
  58. data/lib/klenod/build.rb +15 -0
  59. metadata +310 -0
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Klenod
6
+ module Build
7
+ module Plugins
8
+ module GoogleFontsPlugin
9
+ FontMetric = Data.define(
10
+ :family_name,
11
+ :category,
12
+ :ascent,
13
+ :descent,
14
+ :line_gap,
15
+ :units_per_em,
16
+ :x_width_avg
17
+ )
18
+
19
+ FallbackFont = Data.define(
20
+ :family,
21
+ :local_family,
22
+ :size_adjust,
23
+ :ascent_override,
24
+ :descent_override,
25
+ :line_gap_override
26
+ )
27
+
28
+ class FontMetrics
29
+ DATA_PATH = File.expand_path("font_metrics.json", __dir__)
30
+
31
+ def initialize(path: DATA_PATH)
32
+ @path = path
33
+ end
34
+
35
+ def [](family)
36
+ values = collection[family]
37
+ return unless values
38
+
39
+ FontMetric.new(
40
+ values.fetch("familyName"),
41
+ values.fetch("category"),
42
+ values.fetch("ascent"),
43
+ values.fetch("descent"),
44
+ values.fetch("lineGap"),
45
+ values.fetch("unitsPerEm"),
46
+ values.fetch("xWidthAvg")
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def collection
53
+ @collection ||= JSON.parse(File.binread(@path))
54
+ end
55
+ end
56
+
57
+ class FallbackCalculator
58
+ SERIF_FALLBACK = "Times New Roman"
59
+ MONO_FALLBACK = "Courier New"
60
+ SANS_SERIF_FALLBACK = "Arial"
61
+
62
+ def initialize(metrics)
63
+ @metrics = metrics
64
+ end
65
+
66
+ def call(family)
67
+ font = metrics[family]
68
+ return unless font
69
+
70
+ local_family =
71
+ case font.category
72
+ when "serif" then SERIF_FALLBACK
73
+ when "monospace" then MONO_FALLBACK
74
+ else SANS_SERIF_FALLBACK
75
+ end
76
+ fallback = metrics[local_family]
77
+ return unless fallback
78
+
79
+ font_average_width = font.x_width_avg.fdiv(font.units_per_em)
80
+ fallback_average_width = fallback.x_width_avg.fdiv(fallback.units_per_em)
81
+ size_adjust = (font_average_width.zero? || fallback_average_width.zero?) ? 1 : font_average_width / fallback_average_width
82
+ adjusted_em_square = font.units_per_em * size_adjust
83
+
84
+ FallbackFont.new(
85
+ "#{family} Fallback",
86
+ local_family,
87
+ percentage(size_adjust),
88
+ percentage(font.ascent.fdiv(adjusted_em_square)),
89
+ percentage(font.descent.fdiv(adjusted_em_square)),
90
+ percentage(font.line_gap.fdiv(adjusted_em_square))
91
+ )
92
+ end
93
+
94
+ private
95
+
96
+ attr_reader :metrics
97
+
98
+ def percentage(value)
99
+ format("%.2f%%", value.abs * 100)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,31 @@
1
+ Font metrics in font_metrics.json are taken from the Capsize metrics collection.
2
+
3
+ Update them to the latest Capsize revision from the Klenod repository root:
4
+
5
+ bundle exec rake google_fonts:metrics:update
6
+
7
+ Project: https://github.com/seek-oss/capsize
8
+ Commit: b0eb891c39e2ecd8b680f550bcda6f5fe9e38326
9
+ Source: packages/metrics/src/entireMetricsCollection.json
10
+
11
+ MIT License
12
+
13
+ Copyright (c) 2021 SEEK
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
@@ -0,0 +1,387 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/http/internet"
5
+ require "fileutils"
6
+ require "uri"
7
+
8
+ require_relative "../asset"
9
+ require_relative "../dependency"
10
+ require_relative "../errors"
11
+ require_relative "../hashing"
12
+ require_relative "../module_id"
13
+ require_relative "../plugin"
14
+ require_relative "../transform_result"
15
+ require_relative "google_fonts_plugin/font_metrics"
16
+
17
+ module Klenod
18
+ module Build
19
+ module Plugins
20
+ module GoogleFontsPlugin
21
+ def self.new(...)
22
+ Plugin.new(...)
23
+ end
24
+
25
+ class Plugin < Klenod::Build::Plugin
26
+ GOOGLE_FONTS_HOST = "fonts.googleapis.com"
27
+ GOOGLE_FONTS_PATH = "/css2"
28
+ GOOGLE_FONTS_MODULE_PREFIX = "virtual:klenod/google_fonts"
29
+ FONT_URL_PATTERN = /url\((?<quote>["']?)(?<url>https:\/\/fonts\.gstatic\.com\/[^"')]+)\k<quote>\)/
30
+
31
+ Error = Class.new(StandardError)
32
+ FontFace = Data.define(:family, :style, :weight)
33
+
34
+ class CssCache
35
+ def initialize(path)
36
+ @path = path
37
+ end
38
+
39
+ def read(url)
40
+ path = entry_path(url)
41
+ return nil unless File.file?(path)
42
+
43
+ css = File.binread(path)
44
+ css.empty? ? nil : css
45
+ rescue SystemCallError
46
+ nil
47
+ end
48
+
49
+ def write(url, css)
50
+ path = entry_path(url)
51
+ temp_path = "#{path}.tmp.#{$$}.#{object_id}"
52
+ FileUtils.mkdir_p(File.dirname(path))
53
+ File.binwrite(temp_path, css)
54
+ File.rename(temp_path, path)
55
+ css
56
+ rescue
57
+ FileUtils.rm_f(temp_path) if temp_path
58
+ raise
59
+ end
60
+
61
+ private
62
+
63
+ def entry_path(url)
64
+ hash = Hashing.hexdigest(url)
65
+ File.join(@path, "#{hash}.css")
66
+ end
67
+ end
68
+
69
+ class DefaultFetcher
70
+ def initialize(internet: Async::HTTP::Internet.new)
71
+ @internet = internet
72
+ end
73
+
74
+ def call(url)
75
+ Sync do
76
+ @internet.get(url) do |response|
77
+ raise Error, "HTTP #{response.status}" unless response.success?
78
+
79
+ response.read
80
+ end
81
+ end
82
+ end
83
+
84
+ def write(url, io)
85
+ Sync do
86
+ @internet.get(url) do |response|
87
+ raise Error, "HTTP #{response.status}" unless response.success?
88
+
89
+ if response.body
90
+ response.body.each { |chunk| io.write(chunk) }
91
+ else
92
+ io.write(response.read)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ class FontFaceParser
100
+ FONT_FACE_PATTERN = /@font-face\s*\{(?<body>.*?)\}/m
101
+ FONT_URL_PATTERN = Plugin::FONT_URL_PATTERN
102
+ DECLARATION_PATTERN = /(?<name>font-family|font-style|font-weight)\s*:\s*(?<value>[^;]+);/i
103
+
104
+ def initialize(css)
105
+ @css = css
106
+ end
107
+
108
+ def font_faces_by_url
109
+ css.scan(FONT_FACE_PATTERN).each_with_object({}) do |(body), index|
110
+ font_url = body.match(FONT_URL_PATTERN) { it[:url] }
111
+ next unless font_url
112
+
113
+ declarations = font_face_declarations(body)
114
+ index[font_url] =
115
+ FontFace.new(
116
+ unquote(declarations["font-family"]),
117
+ declarations["font-style"],
118
+ declarations["font-weight"]
119
+ )
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ attr_reader :css
126
+
127
+ def font_face_declarations(body)
128
+ body.scan(DECLARATION_PATTERN).to_h do |name, value|
129
+ [name.downcase, value.strip]
130
+ end
131
+ end
132
+
133
+ def unquote(value)
134
+ value&.delete_prefix("\"")&.delete_suffix("\"")&.delete_prefix("'")&.delete_suffix("'")
135
+ end
136
+ end
137
+
138
+ def initialize(fetcher: nil, cache_path: nil, refresh_cache: false, adjust_font_fallback: true)
139
+ @fetcher = fetcher || DefaultFetcher.new
140
+ @css_cache = cache_path && CssCache.new(cache_path)
141
+ @refresh_cache = refresh_cache
142
+ @adjust_font_fallback = adjust_font_fallback
143
+ @fallback_calculator = FallbackCalculator.new(FontMetrics.new)
144
+ @missing_fallback_metrics = {}
145
+ @assets_by_module_id = {}
146
+ end
147
+
148
+ def resolve(dependency, _context)
149
+ return nil unless dependency.kind == :css_import
150
+ return nil unless google_fonts_url?(dependency.specifier)
151
+
152
+ hash = Hashing.short(dependency.specifier)
153
+ module_id =
154
+ ModuleId.new(
155
+ "#{GOOGLE_FONTS_MODULE_PREFIX}/#{hash}.rb",
156
+ URI.encode_www_form("url" => dependency.specifier)
157
+ )
158
+
159
+ ResolvedDependency.new(dependency, module_id, {virtual: true})
160
+ end
161
+
162
+ def load(module_id, context)
163
+ return nil unless google_fonts_module?(module_id)
164
+
165
+ url = google_fonts_url_for(module_id)
166
+ css = fetch_css(url)
167
+ font_faces = FontFaceParser.new(css).font_faces_by_url
168
+ font_assets = {}
169
+ rewritten_css =
170
+ css.gsub(FONT_URL_PATTERN) do
171
+ quote = Regexp.last_match[:quote]
172
+ font_url = Regexp.last_match[:url]
173
+ asset = font_assets[font_url] ||= font_asset(font_url, font_faces[font_url], context)
174
+
175
+ %(url(#{quote}#{asset.output_path}#{quote}))
176
+ end
177
+ rewritten_css = append_fallback_font_faces(rewritten_css, font_faces.values) if @adjust_font_fallback
178
+
179
+ css_asset = css_asset(module_id, url, rewritten_css, font_assets.keys)
180
+ @assets_by_module_id[module_id] = [css_asset, *font_assets.values]
181
+ ruby_module_source(css_asset.output_path)
182
+ end
183
+
184
+ def transform(module_id, code, _context)
185
+ return super unless google_fonts_module?(module_id)
186
+
187
+ TransformResult.new(
188
+ code,
189
+ [],
190
+ nil,
191
+ @assets_by_module_id.fetch(module_id),
192
+ [],
193
+ {google_fonts_url: google_fonts_url_for(module_id)}
194
+ )
195
+ end
196
+
197
+ private
198
+
199
+ def google_fonts_url?(value)
200
+ uri = URI.parse(value)
201
+ uri.is_a?(URI::HTTPS) &&
202
+ uri.host == GOOGLE_FONTS_HOST &&
203
+ uri.path == GOOGLE_FONTS_PATH
204
+ rescue URI::InvalidURIError
205
+ false
206
+ end
207
+
208
+ def google_fonts_module?(module_id)
209
+ module_id.scheme == :virtual && module_id.path.start_with?("#{GOOGLE_FONTS_MODULE_PREFIX}/")
210
+ end
211
+
212
+ def google_fonts_url_for(module_id)
213
+ URI.decode_www_form(module_id.query || "").to_h.fetch("url")
214
+ end
215
+
216
+ def fetch(url)
217
+ @fetcher.call(url).b
218
+ rescue => error
219
+ raise Error, "Could not download Google Fonts asset #{url.inspect}: #{error.message}"
220
+ end
221
+
222
+ def fetch_css(url)
223
+ if @css_cache && !@refresh_cache
224
+ css = @css_cache.read(url)
225
+ return css if css
226
+ end
227
+
228
+ css = fetch(url)
229
+ @css_cache&.write(url, css)
230
+ css
231
+ end
232
+
233
+ def write_fetch(url, io)
234
+ if @fetcher.respond_to?(:write)
235
+ @fetcher.write(url, io)
236
+ else
237
+ io.write(fetch(url))
238
+ end
239
+ rescue => error
240
+ raise Error, "Could not download Google Fonts asset #{url.inspect}: #{error.message}"
241
+ end
242
+
243
+ def css_asset(module_id, url, css, font_source_urls)
244
+ hash = Hashing.short(css)
245
+ output_path = "/assets/#{google_fonts_asset_name(url)}.#{hash}.css"
246
+
247
+ Asset.new(
248
+ module_id.to_s,
249
+ hash,
250
+ output_path,
251
+ nil,
252
+ css,
253
+ "text/css",
254
+ {type: :css, google_fonts: true, font_source_urls: font_source_urls}
255
+ )
256
+ end
257
+
258
+ def append_fallback_font_faces(css, font_faces)
259
+ fallback_css =
260
+ font_faces
261
+ .filter_map(&:family)
262
+ .uniq
263
+ .filter_map { fallback_font_face_css(it) }
264
+ return css if fallback_css.empty?
265
+
266
+ "#{css.rstrip}\n\n#{fallback_css.join("\n\n")}\n"
267
+ end
268
+
269
+ def fallback_font_face_css(family)
270
+ fallback = @fallback_calculator.call(family)
271
+ unless fallback
272
+ unless @missing_fallback_metrics[family]
273
+ warn "Could not adjust fallback for Google Font #{family.inspect}; run `bundle exec rake google_fonts:metrics:update` to refresh the vendored metrics."
274
+ @missing_fallback_metrics[family] = true
275
+ end
276
+ return
277
+ end
278
+
279
+ <<~CSS.rstrip
280
+ @font-face {
281
+ font-family: #{css_string(fallback.family)};
282
+ src: local(#{css_string(fallback.local_family)});
283
+ size-adjust: #{fallback.size_adjust};
284
+ ascent-override: #{fallback.ascent_override};
285
+ descent-override: #{fallback.descent_override};
286
+ line-gap-override: #{fallback.line_gap_override};
287
+ }
288
+ CSS
289
+ end
290
+
291
+ def css_string(value)
292
+ %("#{value.gsub("\\", "\\\\").gsub("\"", "\\\"")}")
293
+ end
294
+
295
+ def font_asset(url, font_face, context)
296
+ hash = Hashing.short(url)
297
+ uri = URI.parse(url)
298
+ extname = File.extname(uri.path)
299
+ output_path = "/assets/#{font_asset_name(uri, font_face)}.#{hash}#{extname}"
300
+
301
+ Asset.generated(
302
+ url,
303
+ hash,
304
+ output_path,
305
+ nil,
306
+ content_type(extname),
307
+ {
308
+ type: :font,
309
+ google_fonts: true,
310
+ source_url: url,
311
+ family: font_face&.family,
312
+ style: font_face&.style,
313
+ weight: font_face&.weight
314
+ },
315
+ writer: ->(io) { write_fetch(url, io) },
316
+ queue: context.asset_generation_queue,
317
+ queue_kind: :io
318
+ ) do
319
+ fetch(url)
320
+ end
321
+ end
322
+
323
+ def ruby_module_source(css_asset_path)
324
+ <<~RUBY
325
+ CSS_CLASSES = {}.freeze
326
+ CSS_ASSET_PATH = #{css_asset_path.inspect}
327
+ RUBY
328
+ end
329
+
330
+ def google_fonts_asset_name(url)
331
+ uri = URI.parse(url)
332
+ families =
333
+ URI
334
+ .decode_www_form(uri.query || "")
335
+ .filter_map do |key, value|
336
+ next unless key == "family"
337
+
338
+ value.split(":", 2).fetch(0)
339
+ end
340
+
341
+ slug = families.empty? ? "fonts" : families.join("_")
342
+ "google_fonts_#{slugify(slug)}"
343
+ end
344
+
345
+ def font_asset_name(uri, font_face)
346
+ return font_face_asset_name(font_face) if font_face&.family
347
+
348
+ parts = uri.path.split("/").reject(&:empty?)
349
+ useful_parts = parts.last(3)
350
+ useful_parts[-1] = File.basename(useful_parts.fetch(-1), File.extname(useful_parts.fetch(-1)))
351
+
352
+ "google_font_#{slugify(useful_parts.join("_"))}"
353
+ end
354
+
355
+ def font_face_asset_name(font_face)
356
+ parts = [font_face.family, font_face.style, font_face.weight].compact
357
+
358
+ "google_font_#{slugify(parts.join("_"))}"
359
+ end
360
+
361
+ def slugify(value)
362
+ value
363
+ .downcase
364
+ .gsub(/[^a-z0-9]+/, "_")
365
+ .sub(/\A_+/, "")
366
+ .sub(/_+\z/, "")
367
+ end
368
+
369
+ def content_type(extname)
370
+ case extname
371
+ when ".woff2" then "font/woff2"
372
+ when ".woff" then "font/woff"
373
+ when ".ttf" then "font/ttf"
374
+ when ".otf" then "font/otf"
375
+ else "application/octet-stream"
376
+ end
377
+ end
378
+ end
379
+
380
+ Error = Plugin::Error
381
+ FontFace = Plugin::FontFace
382
+ CssCache = Plugin::CssCache
383
+ DefaultFetcher = Plugin::DefaultFetcher
384
+ end
385
+ end
386
+ end
387
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Build
5
+ module Plugins
6
+ module HamlPlugin
7
+ module Companions
8
+ private
9
+
10
+ def companion_patterns(module_id)
11
+ base = module_id.path.delete_suffix(".haml")
12
+
13
+ [
14
+ WatchedPattern.new(module_id, "#{base}.css", :companion_style, {}),
15
+ WatchedPattern.new(module_id, "#{base}.intl.*.toml", :companion_intl, {})
16
+ ]
17
+ end
18
+
19
+ def companion_path(module_id, extname)
20
+ ModuleId.new(module_id.path.delete_suffix(".haml") + extname, nil)
21
+ end
22
+
23
+ def companion_owner_module_id(path, context)
24
+ relative_path = Pathname.new(path).expand_path.relative_path_from(context.source_dir).to_s
25
+ owner_path =
26
+ if relative_path.end_with?(".css")
27
+ relative_path.delete_suffix(".css") + ".haml"
28
+ elsif relative_path.match?(/\.intl\.[^\/]+\.toml\z/)
29
+ relative_path.sub(/\.intl\.[^\/]+\.toml\z/, ".haml")
30
+ end
31
+ return nil unless owner_path
32
+
33
+ owner_id = ModuleId.new(owner_path, nil)
34
+ owner_id if context.absolute_path(owner_id).file?
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "klenod/runtime/source_map"
4
+
5
+ module Klenod
6
+ module Build
7
+ module Plugins
8
+ 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)
21
+ end
22
+
23
+ private
24
+
25
+ def source_line_for(error)
26
+ line = error.line if error.respond_to?(:line)
27
+ line ||= full_message_line_for(error)
28
+ return nil unless line.is_a?(Integer)
29
+
30
+ # Haml reports zero-based line indexes.
31
+ error_line_zero_based?(error) ? line + 1 : line
32
+ end
33
+
34
+ def full_message_line_for(error)
35
+ return nil unless error.respond_to?(:full_message)
36
+
37
+ error.full_message(highlight: false, order: :top).match(/\A\(haml\):(?<line>\d+):/) { it[:line].to_i }
38
+ end
39
+
40
+ def error_line_zero_based?(error)
41
+ error.respond_to?(:line) && error.line.is_a?(Integer) && !error.is_a?(RubyParseError)
42
+ 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
+ end
88
+
89
+ class RubyParseError < StandardError
90
+ attr_reader :line
91
+
92
+ def initialize(message, line: nil)
93
+ @line = line
94
+
95
+ super(message)
96
+ end
97
+ end
98
+
99
+ HamlTransformResult = Data.define(:code, :source_map, :metadata, :ast) do
100
+ def self.from_ast(ast, source:, metadata:)
101
+ code = ast.source
102
+
103
+ new(
104
+ code,
105
+ Runtime::SourceMap::SourceMap.parse(source, code),
106
+ metadata,
107
+ ast
108
+ )
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end