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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0a05a70d821a5b94964b4bade32066b9fd1bdade13d2b3899becdeb46d3d33ef
4
+ data.tar.gz: 68ac76064521f6ca7678d51256672d5ea9161814215bf4c9e3b5a3f743350bc8
5
+ SHA512:
6
+ metadata.gz: 440df52a915ecb0b8312622268b94d413c3447d80912ef42a797a959aaadca42ec3c108a08439d79eb7cf9fff0d7baf4a5425e565d087b4f6b18efee84d0f2df
7
+ data.tar.gz: 1ddb4e48b0156a21160fa3d93f855ab657bc4ed40d323e5f3d2f6bbd9dc3d27b36c093ec9731b1eeb791a4b601499721ce89665c23c84be4ad57c02c84491d9f
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # klenod-build
2
+
3
+ `klenod-build` builds [Klenod](https://github.com/aalin/klenod) bundles.
4
+
5
+ It owns development and build-time behavior:
6
+
7
+ - module resolving and graph collection
8
+ - plugins
9
+ - asset generation
10
+ - bundle serialization
11
+ - a basic `klenod` CLI
12
+ - file watching and updates in development mode
13
+
14
+ Use this gem when building an application, running development tooling, or authoring build plugins.
15
+
16
+ ```ruby
17
+ require "klenod/build"
18
+
19
+ context = Klenod::Build::Context.new(source_dir: "src")
20
+ context.build(entrypoints: ["pages/server"], output: "dist/klenod.bundle")
21
+ ```
22
+
23
+ Then you can load that bundle with [klenod-runtime](https://github.com/aalin/klenod/tree/main/gems/klenod-runtime).
24
+
25
+ The CLI can also generate a [Graphviz](https://graphviz.org/) DOT-file from a generated bundle:
26
+
27
+ ```sh
28
+ bundle exec klenod graph dist/klenod.bundle > graph.dot
29
+ ```
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "fileutils"
5
+ require "stringio"
6
+
7
+ module Klenod
8
+ module Build
9
+ class Asset
10
+ attr_reader :logical_name, :content_hash, :output_path, :source_path, :content_type, :metadata, :queue_kind, :error
11
+
12
+ def initialize(logical_name, content_hash, output_path, source_path, bytes, content_type, metadata, generator: nil, writer: nil, queue: nil, queue_kind: :cpu)
13
+ @logical_name = logical_name
14
+ @content_hash = content_hash
15
+ @output_path = output_path
16
+ @source_path = source_path
17
+ @bytes = bytes
18
+ @content_type = content_type
19
+ @metadata = metadata
20
+ @generator = generator
21
+ @writer = writer
22
+ @queue = queue
23
+ @queue_kind = queue_kind
24
+ @disk_path = nil
25
+ @task = nil
26
+ @mutex = Mutex.new
27
+ @state = (bytes || (source_path && !generator && !writer)) ? :ready : :pending
28
+ @error = nil
29
+ end
30
+
31
+ def self.generated(logical_name, content_hash, output_path, source_path, content_type, metadata, writer: nil, queue: nil, queue_kind: :cpu, &generator)
32
+ new(logical_name, content_hash, output_path, source_path, nil, content_type, metadata, generator: generator, writer: writer, queue: queue, queue_kind: queue_kind)
33
+ end
34
+
35
+ def bytes
36
+ wait
37
+ @bytes || File.binread(@disk_path || @source_path)
38
+ end
39
+
40
+ def wait
41
+ return self if ready?
42
+ raise error if failed?
43
+
44
+ task = start_generation
45
+ task ? wait_for_task(task) : generate_now_or_queued
46
+ raise error if failed?
47
+
48
+ self
49
+ end
50
+
51
+ def ready?
52
+ @state == :ready
53
+ end
54
+
55
+ def pending?
56
+ @state == :pending
57
+ end
58
+
59
+ def running?
60
+ @state == :running
61
+ end
62
+
63
+ def failed?
64
+ @state == :failed
65
+ end
66
+
67
+ def static?
68
+ @generator.nil? && @writer.nil?
69
+ end
70
+
71
+ def generated?
72
+ !static?
73
+ end
74
+
75
+ def write_to(path, &block)
76
+ path = path.to_s
77
+
78
+ return skip_existing(path) if File.file?(path)
79
+
80
+ task = start_write(path, &block)
81
+ task ? wait_for_task(task) : write_now_or_queued(path, &block)
82
+ raise error if failed?
83
+
84
+ :written
85
+ end
86
+
87
+ private
88
+
89
+ def start_generation
90
+ @mutex.synchronize do
91
+ return @task if @task
92
+ return nil if ready?
93
+
94
+ task = current_async_task
95
+ return nil unless task
96
+
97
+ @state = :running
98
+ @task = queued_task(task) { generate_now }
99
+ end
100
+ end
101
+
102
+ def start_write(path, &block)
103
+ @mutex.synchronize do
104
+ return @task if @task
105
+ return nil if ready? && materialized_for?(path)
106
+
107
+ task = current_async_task
108
+ return nil unless task
109
+
110
+ @state = :running
111
+ @task = queued_task(task) { write_now(path, &block) }
112
+ end
113
+ end
114
+
115
+ def wait_for_task(task)
116
+ task.wait
117
+ rescue => e
118
+ fail_with(e)
119
+ raise
120
+ end
121
+
122
+ def generate_now
123
+ return self if ready?
124
+
125
+ @mutex.synchronize do
126
+ return self if ready?
127
+
128
+ @state = :running
129
+ end
130
+ @bytes = generate_bytes
131
+ @mutex.synchronize do
132
+ @state = :ready
133
+ @task = nil
134
+ end
135
+ self
136
+ rescue => e
137
+ fail_with(e)
138
+ raise
139
+ end
140
+
141
+ def write_now(path, &block)
142
+ return skip_existing(path) if File.file?(path)
143
+ return mark_disk_ready(path) if ready? && materialized_for?(path)
144
+
145
+ temp_path = "#{path}.tmp.#{$$}.#{object_id}"
146
+ FileUtils.mkdir_p(File.dirname(path))
147
+ block&.call(generated? ? :generate_start : :write_start, self, path)
148
+
149
+ write_bytes_to(temp_path)
150
+
151
+ File.rename(temp_path, path)
152
+ mark_disk_ready(path)
153
+ rescue => e
154
+ FileUtils.rm_f(temp_path) if temp_path
155
+ fail_with(e)
156
+ raise
157
+ end
158
+
159
+ def fail_with(error)
160
+ @mutex.synchronize do
161
+ @error ||= error
162
+ @state = :failed
163
+ @task = nil
164
+ end
165
+ end
166
+
167
+ def skip_existing(path)
168
+ @mutex.synchronize do
169
+ @disk_path = path
170
+ @state = :ready
171
+ @task = nil
172
+ end
173
+ :skipped
174
+ end
175
+
176
+ def mark_disk_ready(path)
177
+ @mutex.synchronize do
178
+ @disk_path = path
179
+ @state = :ready
180
+ @task = nil
181
+ end
182
+ :written
183
+ end
184
+
185
+ def materialized_for?(path)
186
+ @disk_path == path
187
+ end
188
+
189
+ def current_async_task
190
+ Async::Task.current
191
+ rescue RuntimeError
192
+ nil
193
+ end
194
+
195
+ def queued_task(task, &block)
196
+ return @queue.run(kind: queue_kind, &block) if @queue
197
+
198
+ task.async(&block)
199
+ end
200
+
201
+ def generate_now_or_queued
202
+ return @queue.run(kind: queue_kind) { generate_now } if @queue
203
+
204
+ generate_now
205
+ end
206
+
207
+ def write_now_or_queued(path, &block)
208
+ return @queue.run(kind: queue_kind) { write_now(path, &block) } if @queue
209
+
210
+ write_now(path, &block)
211
+ end
212
+
213
+ def write_bytes_to(path)
214
+ return FileUtils.copy_file(@source_path, path) if static? && @bytes.nil? && @source_path
215
+
216
+ File.open(path, "wb") do |file|
217
+ if @writer
218
+ @writer.call(file)
219
+ else
220
+ file.write(@bytes || generate_bytes)
221
+ end
222
+ end
223
+ end
224
+
225
+ def generate_bytes
226
+ return @generator.call if @generator
227
+ return File.binread(@source_path) unless @writer
228
+
229
+ io = StringIO.new("".b)
230
+ @writer.call(io)
231
+ io.string
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brotli"
4
+ require "fileutils"
5
+
6
+ module Klenod
7
+ module Build
8
+ module AssetCompression
9
+ TEXT_CONTENT_TYPES = [
10
+ "application/javascript",
11
+ "application/json",
12
+ "application/manifest+json",
13
+ "application/rss+xml",
14
+ "application/xml",
15
+ "image/svg+xml"
16
+ ].freeze
17
+
18
+ def self.compressible?(asset)
19
+ content_type = asset.content_type.to_s.split(";", 2).first
20
+ content_type.start_with?("text/") || TEXT_CONTENT_TYPES.include?(content_type)
21
+ end
22
+
23
+ def self.sidecar_path(path)
24
+ "#{path}.br"
25
+ end
26
+
27
+ def self.write_sidecar(asset, path)
28
+ return nil unless compressible?(asset)
29
+
30
+ brotli_path = sidecar_path(path)
31
+ return :skipped if File.file?(brotli_path)
32
+
33
+ bytes = asset.bytes
34
+ compressed = Brotli.deflate(bytes, quality: 11, mode: :text)
35
+ return nil unless compressed.bytesize < bytes.bytesize
36
+
37
+ write_atomic(brotli_path, compressed)
38
+ :written
39
+ end
40
+
41
+ def self.remove_sidecar(path)
42
+ brotli_path = sidecar_path(path)
43
+ FileUtils.rm_f(brotli_path)
44
+ brotli_path
45
+ end
46
+
47
+ def self.write_atomic(path, bytes)
48
+ temp_path = "#{path}.tmp.#{$$}.#{object_id}"
49
+ FileUtils.mkdir_p(File.dirname(path))
50
+ File.binwrite(temp_path, bytes)
51
+ File.rename(temp_path, path)
52
+ rescue
53
+ FileUtils.rm_f(temp_path) if temp_path
54
+ raise
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/semaphore"
5
+ require "etc"
6
+
7
+ module Klenod
8
+ module Build
9
+ class AssetGenerationQueue
10
+ DEFAULT_CONCURRENCY = [Etc.nprocessors / 2, 2].max
11
+ DEFAULT_DOWNLOAD_CONCURRENCY = 4
12
+
13
+ attr_reader :concurrency, :download_concurrency
14
+
15
+ def initialize(concurrency: DEFAULT_CONCURRENCY, download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY)
16
+ @concurrency = Integer(concurrency)
17
+ @download_concurrency = Integer(download_concurrency)
18
+ raise ArgumentError, "concurrency must be positive" unless @concurrency.positive?
19
+ raise ArgumentError, "download_concurrency must be positive" unless @download_concurrency.positive?
20
+
21
+ @semaphores = {
22
+ cpu: Async::Semaphore.new(@concurrency),
23
+ io: Async::Semaphore.new(@download_concurrency)
24
+ }
25
+ end
26
+
27
+ def run(kind: :cpu, &block)
28
+ semaphore = semaphore_for(kind)
29
+ task = current_async_task
30
+ return semaphore.acquire { block.call } unless task
31
+
32
+ semaphore.async(parent: task) { block.call }
33
+ end
34
+
35
+ private
36
+
37
+ def semaphore_for(kind)
38
+ @semaphores.fetch(kind) do
39
+ raise ArgumentError, "unknown asset generation queue kind: #{kind.inspect}"
40
+ end
41
+ end
42
+
43
+ def current_async_task
44
+ Async::Task.current
45
+ rescue RuntimeError
46
+ nil
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ previous_verbose = $VERBOSE
5
+ $VERBOSE = nil
6
+ require "samovar"
7
+ ensure
8
+ $VERBOSE = previous_verbose
9
+ end
10
+
11
+ require_relative "../context"
12
+ require_relative "../graphviz"
13
+ require_relative "../version"
14
+
15
+ module Klenod
16
+ module Build
17
+ module CLI
18
+ class Build < Samovar::Command
19
+ self.description = "Build a Klenod runtime bundle."
20
+
21
+ options do
22
+ option "--executable", "Write a Ruby executable bundle that loads all entrypoints."
23
+ end
24
+
25
+ def call
26
+ config_path = Klenod::Build::ConfigLoader.find
27
+ raise Samovar::InvalidInputError.new(self, ["Could not find klenod.config.rb"]) unless config_path
28
+
29
+ config = nil
30
+ Dir.chdir(File.dirname(config_path)) do
31
+ config = Klenod::Build::ConfigLoader.load(config_path)
32
+ end
33
+ entrypoints = config.entrypoints
34
+ raise Samovar::MissingValueError.new(self, :entry) if entrypoints.empty?
35
+
36
+ output = config.output_path
37
+ assets_dir = config.assets_path
38
+
39
+ bundle = nil
40
+ Dir.chdir(config.base_dir) do
41
+ context = config.context(mode: :build)
42
+ bundle =
43
+ if @options[:executable]
44
+ context.build_executable(entrypoints: entrypoints, output: output, assets_dir: assets_dir)
45
+ else
46
+ context.build(entrypoints: entrypoints, output: output, assets_dir: assets_dir)
47
+ end
48
+ end
49
+
50
+ self.output.puts "Built #{build_kind} #{output}"
51
+ self.output.puts "Source root: #{bundle.source_root}"
52
+ self.output.puts "Entrypoints: #{bundle.entrypoints.keys.join(", ")}"
53
+ self.output.puts "Assets: #{bundle.assets.length}"
54
+ self.output.puts "Assets directory: #{assets_dir}" if assets_dir
55
+
56
+ bundle
57
+ end
58
+
59
+ private
60
+
61
+ def build_kind
62
+ @options[:executable] ? "executable bundle" : "runtime bundle"
63
+ end
64
+ end
65
+
66
+ class Graph < Samovar::Command
67
+ self.description = "Export a Klenod runtime bundle as Graphviz DOT."
68
+
69
+ options do
70
+ option "--no-assets", "Hide generated asset nodes and edges."
71
+ end
72
+
73
+ one :bundle_path, "Path to a Klenod runtime bundle.", required: true
74
+
75
+ def call
76
+ bundle = Klenod::Runtime.load_bundle(@bundle_path)
77
+ dot = Klenod::Build::Graphviz.call(bundle, include_assets: !@options[:no_assets])
78
+ output.write(dot)
79
+ dot
80
+ end
81
+ end
82
+
83
+ class Application < Samovar::Command
84
+ self.description = "Build and develop Klenod applications."
85
+
86
+ options do
87
+ option "-h/--help", "Print this help."
88
+ option "-v/--version", "Print the Klenod version."
89
+ end
90
+
91
+ nested :command, {
92
+ "build" => Build,
93
+ "graph" => Graph
94
+ }
95
+
96
+ def call
97
+ if @options[:version]
98
+ output.puts Klenod::Build::VERSION
99
+ elsif @options[:help] || !@command
100
+ print_usage
101
+ else
102
+ @command.call
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/application"
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Build
5
+ Config =
6
+ Data.define(:source_dir, :entrypoints, :output, :assets_dir, :plugins, :mode, :base_dir) do
7
+ def initialize(source_dir: "src", entrypoints: [], output: "dist/klenod.bundle", assets_dir: nil, plugins: Context::DEFAULT_PLUGINS, mode: :development, base_dir: Dir.pwd)
8
+ super(
9
+ source_dir: source_dir,
10
+ entrypoints: Array(entrypoints),
11
+ output: output,
12
+ assets_dir: assets_dir,
13
+ plugins: plugins,
14
+ mode: mode,
15
+ base_dir: base_dir
16
+ )
17
+ end
18
+
19
+ def with(**attributes)
20
+ self.class.new(**to_h.merge(attributes))
21
+ end
22
+
23
+ def context(**overrides)
24
+ Context.new(
25
+ source_dir: source_path,
26
+ plugins: plugins,
27
+ mode: mode,
28
+ **overrides
29
+ )
30
+ end
31
+
32
+ def source_path
33
+ expand_path(source_dir)
34
+ end
35
+
36
+ def output_path
37
+ expand_path(output)
38
+ end
39
+
40
+ def assets_path
41
+ assets_dir && expand_path(assets_dir)
42
+ end
43
+
44
+ private
45
+
46
+ def expand_path(path)
47
+ path = path.to_s
48
+ return File.expand_path(path) if path.start_with?("/")
49
+
50
+ File.expand_path(path, base_dir)
51
+ end
52
+ end
53
+
54
+ class ConfigBuilder
55
+ def initialize(base_dir: Dir.pwd)
56
+ @source_dir = "src"
57
+ @entrypoints = []
58
+ @output = "dist/klenod.bundle"
59
+ @assets_dir = nil
60
+ @plugins = Context::DEFAULT_PLUGINS
61
+ @mode = :development
62
+ @base_dir = base_dir
63
+ end
64
+
65
+ def source_dir(value = nil)
66
+ return @source_dir unless value
67
+
68
+ @source_dir = value
69
+ end
70
+
71
+ def entrypoint(value)
72
+ @entrypoints << value
73
+ end
74
+
75
+ def entrypoints(*values)
76
+ @entrypoints.concat(values.flatten)
77
+ end
78
+
79
+ def output(value = nil)
80
+ return @output unless value
81
+
82
+ @output = value
83
+ end
84
+
85
+ def assets_dir(value = nil)
86
+ return @assets_dir unless value
87
+
88
+ @assets_dir = value
89
+ end
90
+
91
+ def plugins(value = nil, &block)
92
+ return @plugins unless value || block
93
+
94
+ @plugins = block ? block.call : value
95
+ end
96
+
97
+ def mode(value = nil)
98
+ return @mode unless value
99
+
100
+ @mode = value
101
+ end
102
+
103
+ def config
104
+ Config.new(
105
+ source_dir: @source_dir,
106
+ entrypoints: @entrypoints,
107
+ output: @output,
108
+ assets_dir: @assets_dir,
109
+ plugins: @plugins,
110
+ mode: @mode,
111
+ base_dir: @base_dir
112
+ )
113
+ end
114
+ end
115
+
116
+ module ConfigLoader
117
+ CONFIG_FILE = "klenod.config.rb"
118
+
119
+ module_function
120
+
121
+ def find(start_dir = Dir.pwd)
122
+ dir = File.expand_path(start_dir)
123
+
124
+ loop do
125
+ path = File.join(dir, CONFIG_FILE)
126
+ return path if File.file?(path)
127
+
128
+ parent = File.dirname(dir)
129
+ return nil if parent == dir
130
+
131
+ dir = parent
132
+ end
133
+ end
134
+
135
+ def load(path)
136
+ builder = ConfigBuilder.new(base_dir: File.dirname(File.expand_path(path)))
137
+ builder.instance_eval(File.read(path), path, 1)
138
+ builder.config
139
+ end
140
+ end
141
+ end
142
+ end