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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../plugin"
4
+ require_relative "../ruby_import_rewriter"
5
+ require_relative "../transform_result"
6
+
7
+ module Klenod
8
+ module Build
9
+ module Plugins
10
+ module RubyPlugin
11
+ def self.new(...)
12
+ Plugin.new(...)
13
+ end
14
+
15
+ class Plugin < Klenod::Build::Plugin
16
+ def transform(module_id, code, context)
17
+ return TransformResult.identity(code) unless module_id.extname == ".rb"
18
+
19
+ 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)
28
+ TransformResult.new(result.code, result.dependencies, nil, [], result.watched_patterns, {})
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../asset"
6
+ require_relative "../dependency"
7
+ require_relative "../errors"
8
+ require_relative "../hashing"
9
+ require_relative "../module_id"
10
+ require_relative "../plugin"
11
+ require_relative "../transform_result"
12
+
13
+ module Klenod
14
+ module Build
15
+ module Plugins
16
+ module SvgPlugin
17
+ def self.new(...)
18
+ Plugin.new(...)
19
+ end
20
+
21
+ class Plugin < Klenod::Build::Plugin
22
+ EXTENSIONS = [".svg"].freeze
23
+ SVG_RUNTIME_SPECIFIER = "virtual:klenod/svg"
24
+ SVG_RUNTIME_MODULE_ID = ModuleId.new("#{SVG_RUNTIME_SPECIFIER}.rb", nil)
25
+
26
+ def resolve(dependency, _context)
27
+ return nil unless dependency.specifier == SVG_RUNTIME_SPECIFIER
28
+
29
+ ResolvedDependency.new(dependency, SVG_RUNTIME_MODULE_ID, {virtual: true})
30
+ end
31
+
32
+ def load(module_id, _context)
33
+ return nil unless module_id.scheme == :virtual && module_id == SVG_RUNTIME_MODULE_ID
34
+
35
+ svg_runtime_source
36
+ end
37
+
38
+ def transform(module_id, code, _context)
39
+ return super unless EXTENSIONS.include?(module_id.extname)
40
+
41
+ raise UnsupportedFileError, "SVG imports do not support query options: #{module_id}" if module_id.query
42
+
43
+ dimensions = svg_dimensions(code)
44
+ hash = Hashing.short(code)
45
+ output_path = "/assets/#{asset_name(module_id)}.#{hash}.svg"
46
+ asset =
47
+ Asset.new(
48
+ module_id.path,
49
+ hash,
50
+ output_path,
51
+ nil,
52
+ code,
53
+ "image/svg+xml",
54
+ {
55
+ type: :svg,
56
+ width: dimensions.width,
57
+ height: dimensions.height
58
+ }
59
+ )
60
+ javascript_asset = javascript_svg_asset(module_id, asset)
61
+ svg_runtime_dependency =
62
+ Dependency
63
+ .create(
64
+ specifier: SVG_RUNTIME_SPECIFIER,
65
+ importer_id: module_id,
66
+ kind: :svg_runtime
67
+ )
68
+ .with(id: "#{module_id}:svg_runtime")
69
+
70
+ TransformResult.new(
71
+ svg_module_source(asset, svg_runtime_dependency),
72
+ [svg_runtime_dependency],
73
+ nil,
74
+ [asset, javascript_asset],
75
+ [],
76
+ {asset_bytes: code, svg_javascript_asset_path: javascript_asset.output_path}
77
+ )
78
+ end
79
+
80
+ def import_value(_resolved_dependency, record, context)
81
+ return nil unless EXTENSIONS.include?(record.id.extname)
82
+
83
+ context.mods.fetch(record.id).const_get(:Exports)::Default
84
+ end
85
+
86
+ def runtime_import_value(_resolved_dependency, record, _context)
87
+ return Runtime::DefaultImport.new(:Default) if EXTENSIONS.include?(record.id.extname)
88
+
89
+ super
90
+ end
91
+
92
+ private
93
+
94
+ Dimensions = Data.define(:width, :height)
95
+
96
+ def svg_runtime_source
97
+ <<~RUBY
98
+ Svg = Data.define(:src, :width, :height, :content_type)
99
+ RUBY
100
+ end
101
+
102
+ def svg_module_source(asset, svg_runtime_dependency)
103
+ <<~RUBY
104
+ SvgRuntime = __klenod_import__(#{svg_runtime_dependency.id.inspect})
105
+ Default =
106
+ SvgRuntime::Svg.new(
107
+ src: #{asset.output_path.inspect},
108
+ width: #{asset.metadata[:width].inspect},
109
+ height: #{asset.metadata[:height].inspect},
110
+ content_type: #{asset.content_type.inspect}
111
+ )
112
+
113
+ RUBY
114
+ end
115
+
116
+ def javascript_svg_asset(module_id, asset)
117
+ code = javascript_svg_module_source(asset)
118
+ hash = Hashing.short(code)
119
+ Asset.new(
120
+ module_id.path,
121
+ hash,
122
+ "#{asset.output_path}.js",
123
+ nil,
124
+ code,
125
+ "application/javascript",
126
+ {type: :svg_javascript_metadata, svg_metadata: true, svg_asset_path: asset.output_path}
127
+ )
128
+ end
129
+
130
+ def javascript_svg_module_source(asset)
131
+ metadata = {
132
+ src: asset.output_path,
133
+ width: asset.metadata[:width],
134
+ height: asset.metadata[:height],
135
+ contentType: asset.content_type
136
+ }
137
+
138
+ "export default #{JSON.generate(metadata)};\n"
139
+ end
140
+
141
+ def svg_dimensions(code)
142
+ attributes = svg_attributes(code)
143
+ return Dimensions.new(nil, nil) unless attributes
144
+
145
+ explicit_width = parse_length(attributes["width"])
146
+ explicit_height = parse_length(attributes["height"])
147
+ return Dimensions.new(explicit_width, explicit_height) if explicit_width && explicit_height
148
+
149
+ view_box_dimensions(attributes["viewBox"] || attributes["viewbox"])
150
+ end
151
+
152
+ def svg_attributes(code)
153
+ match = code.match(/<svg(?=[\s>])(?<attributes>[^>]*)>/im)
154
+ return nil unless match
155
+
156
+ match[:attributes].scan(/([:\w.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/).to_h do |name, double_quoted, single_quoted, unquoted|
157
+ [name, double_quoted || single_quoted || unquoted]
158
+ end
159
+ end
160
+
161
+ def view_box_dimensions(view_box)
162
+ values = view_box.to_s.split(/[,\s]+/).filter_map { |value| parse_number(value) }
163
+ return Dimensions.new(nil, nil) unless values.length == 4
164
+
165
+ width = positive_number(values.fetch(2))
166
+ height = positive_number(values.fetch(3))
167
+ Dimensions.new(width, height)
168
+ end
169
+
170
+ def parse_length(value)
171
+ match = value.to_s.strip.match(/\A(?<number>[+-]?(?:\d+(?:\.\d+)?|\.\d+))(?:px)?\z/i)
172
+ return nil unless match
173
+
174
+ positive_number(parse_number(match[:number]))
175
+ end
176
+
177
+ def parse_number(value)
178
+ number = Float(value, exception: false)
179
+ return nil unless number&.finite?
180
+
181
+ number
182
+ end
183
+
184
+ def positive_number(number)
185
+ return nil unless number&.positive?
186
+
187
+ (number == number.to_i) ? number.to_i : number
188
+ end
189
+
190
+ def asset_name(module_id)
191
+ File.basename(module_id.path, module_id.extname).gsub(/[^A-Za-z0-9]+/, "_")
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Build
5
+ module Plugins
6
+ autoload :RubyPlugin, File.expand_path("plugins/ruby_plugin", __dir__)
7
+ autoload :IntlPlugin, File.expand_path("plugins/intl_plugin", __dir__)
8
+ autoload :HamlPlugin, File.expand_path("plugins/haml_plugin", __dir__)
9
+ autoload :MarkdownPlugin, File.expand_path("plugins/markdown_plugin", __dir__)
10
+ autoload :GemImportPlugin, File.expand_path("plugins/gem_import_plugin", __dir__)
11
+ autoload :GoogleFontsPlugin, File.expand_path("plugins/google_fonts_plugin", __dir__)
12
+ autoload :SvgPlugin, File.expand_path("plugins/svg_plugin", __dir__)
13
+ autoload :ImagePlugin, File.expand_path("plugins/image_plugin", __dir__)
14
+ autoload :DataPlugin, File.expand_path("plugins/data_plugin", __dir__)
15
+ autoload :JsonPlugin, File.expand_path("plugins/data_plugin", __dir__)
16
+ autoload :YamlPlugin, File.expand_path("plugins/data_plugin", __dir__)
17
+ autoload :TomlPlugin, File.expand_path("plugins/data_plugin", __dir__)
18
+ autoload :TextPlugin, File.expand_path("plugins/data_plugin", __dir__)
19
+ autoload :RouterPlugin, File.expand_path("plugins/router_plugin", __dir__)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Build
5
+ class Profiler
6
+ Event = Data.define(:name, :duration, :metadata)
7
+
8
+ def initialize(enabled: false, store_events: true, categories: [], progress: nil)
9
+ @enabled = enabled
10
+ @events = store_events ? [] : nil
11
+ @totals = Hash.new(0.0)
12
+ @totals_by_plugin = Hash.new(0.0)
13
+ @counts = Hash.new(0)
14
+ @categories = categories.map(&:to_sym).to_h { |category| [category, true] }
15
+ @progress = progress
16
+ end
17
+
18
+ def enabled?
19
+ @enabled
20
+ end
21
+
22
+ def events
23
+ @events || []
24
+ end
25
+
26
+ def measure(name, metadata = nil)
27
+ return yield unless enabled?
28
+
29
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
30
+ result = yield
31
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
32
+ record(name, duration, metadata || {})
33
+ result
34
+ rescue
35
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at if started_at
36
+ record(name, duration, (metadata || {}).merge(error: true)) if duration
37
+ raise
38
+ end
39
+
40
+ def category?(name)
41
+ @categories.key?(name.to_sym)
42
+ end
43
+
44
+ def count(name, value = 1)
45
+ return unless enabled?
46
+
47
+ @counts[name] += value
48
+ end
49
+
50
+ def progress(name, metadata = {})
51
+ return unless enabled? && @progress
52
+
53
+ @progress.call(name, metadata)
54
+ end
55
+
56
+ def totals
57
+ @totals.dup
58
+ end
59
+
60
+ def totals_by_plugin
61
+ @totals_by_plugin.dup
62
+ end
63
+
64
+ def counts
65
+ @counts.dup
66
+ end
67
+
68
+ private
69
+
70
+ def record(name, duration, metadata)
71
+ @events << Event.new(name, duration, metadata) if @events
72
+ @totals[name] += duration
73
+
74
+ plugin = metadata[:plugin]
75
+ @totals_by_plugin[[name, plugin]] += duration if plugin
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Klenod
6
+ module Build
7
+ module ResolutionErrorFormatter
8
+ ANSI_BACKGROUND = "\e[0;48;5;52m"
9
+
10
+ module_function
11
+
12
+ def format(error, source_root: nil, source_context: nil, ansi: false)
13
+ return error.message unless error.resolution_failure?
14
+
15
+ lines = [error.title, "", " Import: #{error.requested_specifier}"]
16
+ lines << " Imported by: #{error.imported_by}" if error.imported_by
17
+ lines << " Source root: #{source_root}" if source_root
18
+ append_suggestions(lines, error)
19
+ lines.concat(["", source_context]) if source_context
20
+ text = lines.join("\n")
21
+ ansi ? format_ansi(text) : text
22
+ end
23
+
24
+ def format_ansi(text)
25
+ title, separator, body = text.partition("\n")
26
+ body = body.gsub("\e[0m", ANSI_BACKGROUND)
27
+ body = "\n#{body}" unless separator.empty?
28
+
29
+ "\e[1;31;47m ERROR \e[1;37;41m #{title} #{ANSI_BACKGROUND}#{body}\e[0m"
30
+ end
31
+ private_class_method :format_ansi
32
+
33
+ def append_suggestions(lines, error)
34
+ return if error.suggestions.empty?
35
+
36
+ lines.concat(["", (error.reason == :incorrect_case) ? " Use:" : " Did you mean?"])
37
+ error.suggestions.each { |suggestion| lines << " - #{suggestion}" }
38
+ end
39
+ private_class_method :append_suggestions
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "filesystem_resolver"
5
+ require_relative "module_id"
6
+ require_relative "dependency"
7
+
8
+ module Klenod
9
+ module Build
10
+ class Resolver
11
+ DEFAULT_EXTENSIONS = [".rb", ".haml"].freeze
12
+
13
+ def initialize(source_dir:, extensions: DEFAULT_EXTENSIONS, profiler: nil)
14
+ @source_dir_path = File.expand_path(source_dir)
15
+ @source_dir = Pathname.new(@source_dir_path)
16
+ @extensions = extensions
17
+ @profiler = profiler
18
+ @filesystem_resolver = FilesystemResolver.new(root: @source_dir, extensions: @extensions)
19
+ @resolved_module_ids = {}
20
+ @absolute_paths = {}
21
+ end
22
+
23
+ attr_reader :source_dir
24
+
25
+ def resolve(dependency)
26
+ module_id = resolve_app_module_id(dependency)
27
+ raise ResolveError, "Unknown import scheme #{module_id.scheme.inspect} for #{dependency.specifier.inspect}" unless module_id.scheme == :app
28
+
29
+ base_path = File.expand_path(module_id.relative_path, @source_dir_path)
30
+ assert_inside_source_dir!(base_path)
31
+ key = [base_path, module_id.query]
32
+ resolved_module_id = @resolved_module_ids[key]
33
+ if resolved_module_id
34
+ @profiler&.count(:resolver_cache_hit)
35
+ else
36
+ @profiler&.count(:resolver_cache_miss)
37
+ resolved_path = @filesystem_resolver.resolve(module_id.relative_path)
38
+ relative = relative_source_path(resolved_path)
39
+ resolved_module_id = @resolved_module_ids[key] = ModuleId.new("app:/#{relative}", module_id.query)
40
+ end
41
+
42
+ ResolvedDependency.new(dependency, resolved_module_id, {})
43
+ rescue ResolveError => error
44
+ raise error.with_path_prefix("app:/").with_resolution_context(dependency: dependency, importer_id: dependency.importer_id)
45
+ end
46
+
47
+ def absolute_path(module_id)
48
+ path = @absolute_paths[module_id.to_s]
49
+ if path
50
+ @profiler&.count(:resolver_absolute_path_cache_hit)
51
+ return path
52
+ end
53
+
54
+ @profiler&.count(:resolver_absolute_path_cache_miss)
55
+ @absolute_paths.fetch(module_id.to_s) do |path_key|
56
+ raise ResolveError, "Cannot map non-app module to source_dir: #{module_id}" unless module_id.scheme == :app
57
+
58
+ path = Pathname.new(File.expand_path(module_id.relative_path, @source_dir_path))
59
+ assert_inside_source_dir!(path)
60
+ @absolute_paths[path_key] = path
61
+ end
62
+ end
63
+
64
+ def clear_cache
65
+ @resolved_module_ids.clear
66
+ @absolute_paths.clear
67
+ end
68
+
69
+ private
70
+
71
+ def resolve_app_module_id(dependency)
72
+ specifier = dependency.specifier.to_s
73
+ return ModuleId.parse(specifier) if specifier.match?(ModuleId::SCHEME_PATTERN)
74
+
75
+ importer_id = dependency.importer_id
76
+ if importer_id
77
+ importer_id.merge(specifier)
78
+ else
79
+ ModuleId.new("app:/#{specifier.delete_prefix("/")}")
80
+ end
81
+ end
82
+
83
+ def assert_inside_source_dir!(path)
84
+ expanded = File.expand_path(path.to_s)
85
+ return if expanded == @source_dir_path || expanded.start_with?("#{@source_dir_path}/")
86
+
87
+ raise ResolveError, "Resolved path escapes source_dir: #{path}"
88
+ end
89
+
90
+ def relative_source_path(path)
91
+ Pathname.new(path).relative_path_from(@source_dir).to_s
92
+ end
93
+ end
94
+ end
95
+ end