zui 0.0.6 → 0.0.7

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.
@@ -0,0 +1,527 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "ripper"
6
+ require "set"
7
+
8
+ module Zui
9
+ class TreeShaker
10
+ CONFIG_FILE = ".zui-bundle.json"
11
+ EXCLUDED_SOURCE_DIRECTORIES = %w[.git dist node_modules spec test tmp vendor].freeze
12
+ EXCLUDED_SOURCE_FILES = %w[config.rb].freeze
13
+ BASE_COMPONENTS = %i[container].freeze
14
+ RUBY_ONLY_DSL_METHODS = %w[animation state].freeze
15
+ COMPONENT_DISPATCH_METHODS = %w[component dynamic qml_component send public_send widget].freeze
16
+ BASE_QML_MODULES = %w[
17
+ QML QtCore QtQml QtQml.Models QtQml.WorkerScript QtQuick QtQuick.Window
18
+ QtQuick.Controls QtQuick.Controls.impl QtQuick.Controls.Basic
19
+ QtQuick.Controls.Basic.impl QtQuick.Controls.Fusion QtQuick.Controls.Fusion.impl
20
+ QtQuick.Layouts QtQuick.Templates
21
+ ].freeze
22
+ IMAGE_COMPONENTS = %i[
23
+ alert_dialog animated_image avatar border_image carousel image menu navigation_rail
24
+ tab_button vector_animation vector_image
25
+ ].freeze
26
+ NETWORK_COMPONENTS = (IMAGE_COMPONENTS + %i[audio media_player video]).freeze
27
+ TEXT_INPUT_COMPONENTS = %i[
28
+ color_picker date_picker double_spin_box file_picker folder_picker font_picker number_field
29
+ password_field search_field spin_box text_area text_field time_picker
30
+ ].freeze
31
+
32
+ Report = Struct.new(:components, :qml_modules, :before_bytes, :after_bytes, :warnings,
33
+ keyword_init: true) do
34
+ def saved_bytes = before_bytes - after_bytes
35
+
36
+ def to_h
37
+ {
38
+ "components" => components.map(&:to_s),
39
+ "qml_modules" => qml_modules,
40
+ "before_bytes" => before_bytes,
41
+ "after_bytes" => after_bytes,
42
+ "saved_bytes" => saved_bytes,
43
+ "warnings" => warnings
44
+ }
45
+ end
46
+ end
47
+
48
+ def initialize(project:, framework:, native:, platform: Platform.current)
49
+ @project = File.expand_path(project)
50
+ @framework = File.expand_path(framework)
51
+ @native = File.expand_path(native)
52
+ @platform = platform.assert_supported!
53
+ @warnings = []
54
+ end
55
+
56
+ def shake!
57
+ before_bytes = tree_bytes(@framework) + tree_bytes(@native)
58
+ components, adapters = analyze_components
59
+ prune_framework(adapters)
60
+ imports = qml_imports(Dir[File.join(@framework, "**", "*.qml")])
61
+ qml_modules = prune_native_qml(imports)
62
+ prune_native_plugins(components, qml_modules)
63
+ prune_translations
64
+ prune_native_libraries
65
+ update_client_manifest(components, qml_modules)
66
+ after_bytes = tree_bytes(@framework) + tree_bytes(@native)
67
+ Report.new(components: components.sort, qml_modules: qml_modules.sort,
68
+ before_bytes:, after_bytes:, warnings: @warnings.dup.freeze)
69
+ end
70
+
71
+ private
72
+
73
+ def analyze_components
74
+ known = COMPONENTS.keys.to_h { |name| [name.to_s, name] }
75
+ selected = Set.new(BASE_COMPONENTS)
76
+ ruby_sources.each do |path|
77
+ syntax = Ripper.sexp(File.read(path))
78
+ raise ArgumentError, "cannot tree-shake Ruby file with syntax errors: #{path}" unless syntax
79
+
80
+ component_references(syntax, known).each { |component| selected << component }
81
+ end
82
+ configured_components.each do |name|
83
+ selected << known.fetch(name) do
84
+ raise ArgumentError, "unknown component in #{CONFIG_FILE}: #{name}"
85
+ end
86
+ end
87
+
88
+ adapters_by_type = COMPONENTS.keys.to_h { |name| [name, adapter_name(name)] }
89
+ types_by_adapter = adapters_by_type.invert
90
+ adapters = Set.new(selected.map { |name| adapters_by_type.fetch(name) })
91
+ queue = adapters.to_a
92
+ until queue.empty?
93
+ adapter = queue.shift
94
+ path = File.join(@framework, "Components", "Builtins", adapter)
95
+ raise ArgumentError, "component adapter is missing while tree-shaking: #{path}" unless File.file?(path)
96
+
97
+ File.read(path).scan(/\bBuiltins\.([A-Z][A-Za-z0-9]*)/).flatten.each do |class_name|
98
+ dependency = "#{class_name}.qml"
99
+ next unless types_by_adapter.key?(dependency) && adapters.add?(dependency)
100
+
101
+ selected << types_by_adapter.fetch(dependency)
102
+ queue << dependency
103
+ end
104
+ end
105
+ [selected.to_a, adapters]
106
+ end
107
+
108
+ def component_references(node, known, found = Set.new)
109
+ return found unless node.is_a?(Array)
110
+
111
+ case node.first
112
+ when :vcall, :fcall
113
+ add_method_reference(found, known, identifier_value(node[1]))
114
+ when :command
115
+ method_name = identifier_value(node[1])
116
+ add_method_reference(found, known, method_name)
117
+ add_literal_references(found, known, node[2]) if COMPONENT_DISPATCH_METHODS.include?(method_name)
118
+ when :method_add_arg
119
+ method_name = call_name(node[1])
120
+ add_method_reference(found, known, method_name)
121
+ add_literal_references(found, known, node[2]) if COMPONENT_DISPATCH_METHODS.include?(method_name)
122
+ end
123
+ node.each { |child| component_references(child, known, found) if child.is_a?(Array) }
124
+ found
125
+ end
126
+
127
+ def call_name(node)
128
+ return nil unless node.is_a?(Array)
129
+ return identifier_value(node[1]) if %i[fcall vcall].include?(node.first)
130
+ nil
131
+ end
132
+
133
+ def identifier_value(node)
134
+ node.is_a?(Array) && node.first.to_s.start_with?("@") ? node[1].to_s : nil
135
+ end
136
+
137
+ def literal_values(node, values = [])
138
+ return values unless node.is_a?(Array)
139
+
140
+ if node.first == :symbol_literal
141
+ token = node.flatten.find { |part| part.is_a?(String) }
142
+ values << token if token
143
+ elsif node.first == :string_literal
144
+ token = node.flatten.find { |part| part.is_a?(String) }
145
+ values << token if token
146
+ else
147
+ node.each { |child| literal_values(child, values) if child.is_a?(Array) }
148
+ end
149
+ values
150
+ end
151
+
152
+ def add_known_reference(found, known, value)
153
+ component = known[value]
154
+ found << component if component
155
+ end
156
+
157
+ def add_method_reference(found, known, value)
158
+ return if RUBY_ONLY_DSL_METHODS.include?(value)
159
+
160
+ add_known_reference(found, known, value)
161
+ end
162
+
163
+ def add_literal_references(found, known, arguments)
164
+ literal_values(arguments).each { |value| add_known_reference(found, known, value) }
165
+ end
166
+
167
+ def ruby_sources
168
+ Dir[File.join(@project, "**", "*.rb")].select do |path|
169
+ relative = path.delete_prefix("#{@project}#{File::SEPARATOR}")
170
+ !EXCLUDED_SOURCE_FILES.include?(relative) &&
171
+ !EXCLUDED_SOURCE_DIRECTORIES.include?(relative.split(File::SEPARATOR).first)
172
+ end.sort
173
+ end
174
+
175
+ def configured_components
176
+ path = File.join(@project, CONFIG_FILE)
177
+ return [] unless File.file?(path)
178
+
179
+ document = JSON.parse(File.read(path))
180
+ raise ArgumentError, "#{CONFIG_FILE} must contain a JSON object" unless document.is_a?(Hash)
181
+ components = document.fetch("components", [])
182
+ unless components.is_a?(Array) && components.all? { |name| name.is_a?(String) && !name.empty? }
183
+ raise ArgumentError, "#{CONFIG_FILE} components must be an array of component names"
184
+ end
185
+ components
186
+ rescue JSON::ParserError => error
187
+ raise ArgumentError, "invalid #{CONFIG_FILE}: #{error.message}"
188
+ end
189
+
190
+ def adapter_name(name)
191
+ "#{name.to_s.split('_').map(&:capitalize).join}.qml"
192
+ end
193
+
194
+ def prune_framework(adapters)
195
+ builtins = File.join(@framework, "Components", "Builtins")
196
+ Dir[File.join(builtins, "*.qml")].each do |path|
197
+ FileUtils.rm_f(path) unless adapters.include?(File.basename(path))
198
+ end
199
+ sources = adapters.map { |name| File.join(builtins, name) }
200
+ support_required = sources.any? do |path|
201
+ source = File.read(path)
202
+ source.include?('import "Support"') || source.include?("Support/")
203
+ end
204
+ shaders_required = adapters.include?("ShaderEffect.qml")
205
+ remove_tree(File.join(builtins, "Support")) unless support_required
206
+ remove_tree(File.join(builtins, "Shaders")) unless shaders_required
207
+ end
208
+
209
+ def qml_imports(paths)
210
+ paths.each_with_object(Set.new) do |path, imports|
211
+ File.foreach(path) do |line|
212
+ line.scan(/(?:^|["'])\s*import\s+([A-Za-z][A-Za-z0-9_.]*)\b/) do |match|
213
+ imports << match.first
214
+ end
215
+ end
216
+ end
217
+ end
218
+
219
+ def prune_native_qml(framework_imports)
220
+ root = native_path_for("QML_IMPORT_PATH")
221
+ return [] unless root && File.directory?(root)
222
+
223
+ modules = module_directories(root)
224
+ required = Set.new((BASE_QML_MODULES + framework_imports.to_a).select { |name| modules.key?(name) })
225
+ queue = required.to_a
226
+ until queue.empty?
227
+ name = queue.shift
228
+ module_imports(modules.fetch(name), modules).each do |dependency|
229
+ next unless modules.key?(dependency) && required.add?(dependency)
230
+
231
+ queue << dependency
232
+ end
233
+ end
234
+
235
+ module_paths = modules.values
236
+ modules.sort_by { |_name, path| -path.count(File::SEPARATOR) }.each do |name, path|
237
+ next if required.include?(name)
238
+ next if required.any? { |kept| descendant?(modules.fetch(kept), path) }
239
+
240
+ remove_tree(path)
241
+ end
242
+ required.to_a
243
+ end
244
+
245
+ def module_directories(root)
246
+ Dir[File.join(root, "**", "qmldir")].each_with_object({}) do |path, modules|
247
+ module_name = File.foreach(path).filter_map { |line| line[/^\s*module\s+(\S+)/, 1] }.first
248
+ modules[module_name] = File.dirname(path) if module_name
249
+ end
250
+ end
251
+
252
+ def module_imports(directory, modules)
253
+ imports = Set.new
254
+ qmldir = File.join(directory, "qmldir")
255
+ File.foreach(qmldir) do |line|
256
+ dependency = line[/^\s*(?:depends|import)\s+([A-Za-z][A-Za-z0-9_.]*)\b/, 1]
257
+ imports << dependency if dependency
258
+ end
259
+ nested = modules.values.reject { |candidate| candidate == directory }
260
+ .select { |candidate| descendant?(candidate, directory) }
261
+ qml_files = Dir[File.join(directory, "**", "*.qml")].reject do |path|
262
+ nested.any? { |child| descendant?(path, child) }
263
+ end
264
+ imports.merge(qml_imports(qml_files))
265
+ imports
266
+ end
267
+
268
+ def native_path_for(environment_name)
269
+ manifest_path = File.join(@native, "client.json")
270
+ return nil unless File.file?(manifest_path)
271
+
272
+ manifest = JSON.parse(File.read(manifest_path))
273
+ relative = Array(manifest.fetch("environment", {})[environment_name]).first
274
+ return nil unless relative
275
+
276
+ path = File.expand_path(relative, @native)
277
+ unless path == @native || descendant?(path, @native)
278
+ raise ArgumentError, "unsafe native client path while tree-shaking: #{relative.inspect}"
279
+ end
280
+ path
281
+ end
282
+
283
+ def prune_native_plugins(components, qml_modules)
284
+ root = native_path_for("QT_PLUGIN_PATH")
285
+ return unless root && File.directory?(root)
286
+
287
+ component_set = components.to_set
288
+ has_images = !(component_set & IMAGE_COMPONENTS).empty?
289
+ has_network = !(component_set & NETWORK_COMPONENTS).empty?
290
+ has_text_input = !(component_set & TEXT_INPUT_COMPONENTS).empty?
291
+ has_multimedia = qml_modules.include?("QtMultimedia")
292
+ has_quick3d = qml_modules.any? { |name| name.start_with?("QtQuick3D") }
293
+
294
+ remove_tree(File.join(root, "assetimporters")) unless has_quick3d
295
+ remove_tree(File.join(root, "multimedia")) unless has_multimedia
296
+ remove_tree(File.join(root, "imageformats")) unless has_images
297
+ remove_tree(File.join(root, "iconengines")) unless has_images
298
+ remove_tree(File.join(root, "networkinformation")) unless has_network
299
+ remove_tree(File.join(root, "tls")) unless has_network
300
+ remove_tree(File.join(root, "platformthemes")) if @platform.linux?
301
+ remove_tree(File.join(root, "platforminputcontexts")) if @platform.linux? && !has_text_input
302
+ remove_tree(File.join(root, "generic")) if @platform.linux?
303
+ FileUtils.rm_f(File.join(root, "wayland-decoration-client", "libadwaita.so")) if @platform.linux?
304
+ prune_linux_platform_plugins(root) if @platform.linux?
305
+ end
306
+
307
+ def prune_linux_platform_plugins(plugin_root)
308
+ platforms = File.join(plugin_root, "platforms")
309
+ return unless File.directory?(platforms)
310
+
311
+ keep = /\A(?:libqxcb|libqwayland(?:-[A-Za-z0-9_+-]+)?|libqoffscreen)\.so(?:\..*)?\z/
312
+ Dir.children(platforms).each do |name|
313
+ path = File.join(platforms, name)
314
+ FileUtils.rm_f(path) if File.file?(path) && !name.match?(keep)
315
+ end
316
+ end
317
+
318
+ def prune_translations
319
+ candidates = [File.join(@native, "translations")]
320
+ if @platform.macos?
321
+ candidates << File.join(@native, "zui-host.app", "Contents", "Resources", "translations")
322
+ end
323
+ candidates.each { |path| remove_tree(path) }
324
+ end
325
+
326
+ def prune_native_libraries
327
+ if @platform.linux?
328
+ prune_linux_libraries
329
+ elsif @platform.macos?
330
+ prune_macos_frameworks
331
+ elsif @platform.windows?
332
+ prune_windows_libraries
333
+ end
334
+ end
335
+
336
+ def prune_linux_libraries
337
+ library_root = File.join(@native, "lib")
338
+ return unless File.directory?(library_root)
339
+
340
+ available = Dir.children(library_root).to_h { |name| [name, File.join(library_root, name)] }
341
+ roots = native_binary_roots(["*.so", "*.so.*"])
342
+ closure = Set.new
343
+ queue = roots.dup
344
+ inspected = Set.new
345
+ successful = false
346
+ begin
347
+ until queue.empty?
348
+ binary = queue.shift
349
+ next unless File.file?(binary) && inspected.add?(binary)
350
+
351
+ result = Command.run(["ldd", binary], env: { "LD_LIBRARY_PATH" => library_root },
352
+ timeout: 30, max_output_bytes: 2_000_000)
353
+ next unless result.success?
354
+
355
+ successful = true
356
+ result.stdout.each_line do |line|
357
+ name = line[/^\s*(\S+)\s+=>/, 1]
358
+ path = line[/=>\s+(\/\S+)/, 1] || line[/^\s*(\/\S+)/, 1]
359
+ name ||= File.basename(path) if path
360
+ next unless name && available.key?(name) && closure.add?(name)
361
+
362
+ queue << available.fetch(name)
363
+ end
364
+ end
365
+ rescue Errno::ENOENT, CommandTimeout, CommandOutputLimit
366
+ @warnings << "native Linux library analysis was unavailable; libraries were retained"
367
+ return
368
+ end
369
+ return unless successful
370
+
371
+ available.each do |name, path|
372
+ FileUtils.rm_f(path) unless closure.include?(name) || name.start_with?(".")
373
+ end
374
+ end
375
+
376
+ def prune_macos_frameworks
377
+ root = File.join(@native, "zui-host.app", "Contents", "Frameworks")
378
+ return unless File.directory?(root)
379
+
380
+ available = Dir[File.join(root, "*.framework")].to_h { |path| [File.basename(path), path] }
381
+ dylibs = Dir[File.join(root, "*.dylib")].to_h { |path| [File.basename(path), path] }
382
+ roots = native_binary_roots(["*.dylib", "*.so"])
383
+ queue = roots.dup
384
+ required = Set.new
385
+ required_dylibs = Set.new
386
+ inspected = Set.new
387
+ successful = false
388
+ begin
389
+ until queue.empty?
390
+ binary = queue.shift
391
+ next unless File.file?(binary) && inspected.add?(binary)
392
+
393
+ result = Command.run(["otool", "-L", binary], timeout: 30, max_output_bytes: 2_000_000)
394
+ next unless result.success?
395
+
396
+ successful = true
397
+ result.stdout.scan(%r{([^/\s]+\.framework)/}).flatten.each do |name|
398
+ next unless available.key?(name) && required.add?(name)
399
+
400
+ framework_binary = canonical_framework_binary(available.fetch(name), name.delete_suffix(".framework"))
401
+ queue << framework_binary if framework_binary
402
+ end
403
+ result.stdout.each_line do |line|
404
+ dependency = line.strip.split(/\s+\(/, 2).first
405
+ name = File.basename(dependency.to_s)
406
+ next unless dylibs.key?(name) && required_dylibs.add?(name)
407
+
408
+ queue << dylibs.fetch(name)
409
+ end
410
+ end
411
+ rescue Errno::ENOENT, CommandTimeout, CommandOutputLimit
412
+ @warnings << "native macOS framework analysis was unavailable; frameworks were retained"
413
+ return
414
+ end
415
+ return unless successful
416
+
417
+ available.each { |name, path| remove_tree(path) unless required.include?(name) }
418
+ dylibs.each { |name, path| FileUtils.rm_f(path) unless required_dylibs.include?(name) }
419
+ end
420
+
421
+ def canonical_framework_binary(framework, name)
422
+ Dir[File.join(framework, "**", name)].find { |path| File.file?(path) }
423
+ end
424
+
425
+ def prune_windows_libraries
426
+ bin = File.join(@native, "bin")
427
+ return unless File.directory?(bin)
428
+
429
+ available = Dir[File.join(bin, "*.dll")].to_h { |path| [File.basename(path).downcase, path] }
430
+ queue = native_binary_roots(["*.dll"])
431
+ required = Set.new
432
+ inspected = Set.new
433
+ parsed = false
434
+ until queue.empty?
435
+ binary = queue.shift
436
+ next unless File.file?(binary) && inspected.add?(binary)
437
+
438
+ dependencies = pe_dependencies(binary)
439
+ parsed = true unless dependencies.empty?
440
+ dependencies.each do |name|
441
+ key = name.downcase
442
+ next unless available.key?(key) && required.add?(key)
443
+
444
+ queue << available.fetch(key)
445
+ end
446
+ end
447
+ return unless parsed
448
+
449
+ available.each { |name, path| FileUtils.rm_f(path) unless required.include?(name) }
450
+ end
451
+
452
+ def pe_dependencies(path)
453
+ data = File.binread(path)
454
+ return [] unless data.start_with?("MZ") && data.bytesize >= 64
455
+
456
+ pe_offset = data.byteslice(0x3c, 4)&.unpack1("V")
457
+ return [] unless pe_offset && data.byteslice(pe_offset, 4) == "PE\0\0"
458
+
459
+ section_count = data.byteslice(pe_offset + 6, 2).unpack1("v")
460
+ optional_size = data.byteslice(pe_offset + 20, 2).unpack1("v")
461
+ optional = pe_offset + 24
462
+ magic = data.byteslice(optional, 2).unpack1("v")
463
+ directory = optional + (magic == 0x20b ? 112 : 96)
464
+ import_rva = data.byteslice(directory + 8, 4)&.unpack1("V")
465
+ return [] unless import_rva&.positive?
466
+
467
+ sections = section_count.times.map do |index|
468
+ offset = optional + optional_size + index * 40
469
+ [data.byteslice(offset + 12, 4).unpack1("V"), data.byteslice(offset + 8, 4).unpack1("V"),
470
+ data.byteslice(offset + 16, 4).unpack1("V"), data.byteslice(offset + 20, 4).unpack1("V")]
471
+ end
472
+ rva_offset = lambda do |rva|
473
+ section = sections.find { |virtual, size, raw_size, _raw| rva >= virtual && rva < virtual + [size, raw_size].max }
474
+ section && section[3] + rva - section[0]
475
+ end
476
+ descriptor = rva_offset.call(import_rva)
477
+ return [] unless descriptor
478
+
479
+ dependencies = []
480
+ loop do
481
+ name_rva = data.byteslice(descriptor + 12, 4)&.unpack1("V")
482
+ break unless name_rva&.positive?
483
+
484
+ name_offset = rva_offset.call(name_rva)
485
+ break unless name_offset
486
+ name = data.byteslice(name_offset..)&.split("\0", 2)&.first
487
+ dependencies << name if name&.match?(/\.dll\z/i)
488
+ descriptor += 20
489
+ end
490
+ dependencies
491
+ rescue NoMethodError, RangeError
492
+ []
493
+ end
494
+
495
+ def native_binary_roots(patterns)
496
+ manifest = JSON.parse(File.read(File.join(@native, "client.json")))
497
+ roots = [File.join(@native, manifest.fetch("executable"))]
498
+ [native_path_for("QML_IMPORT_PATH"), native_path_for("QT_PLUGIN_PATH")].compact.each do |root|
499
+ patterns.each { |pattern| roots.concat(Dir[File.join(root, "**", pattern)]) }
500
+ end
501
+ roots.uniq
502
+ end
503
+
504
+ def update_client_manifest(components, qml_modules)
505
+ path = File.join(@native, "client.json")
506
+ return unless File.file?(path)
507
+
508
+ manifest = JSON.parse(File.read(path))
509
+ manifest["tree_shaken"] = true
510
+ manifest["components"] = components.map(&:to_s).sort
511
+ manifest["qml_modules"] = qml_modules.sort
512
+ File.write(path, "#{JSON.pretty_generate(manifest)}\n")
513
+ end
514
+
515
+ def descendant?(path, parent)
516
+ path.start_with?("#{parent}#{File::SEPARATOR}")
517
+ end
518
+
519
+ def remove_tree(path)
520
+ FileUtils.remove_entry(path) if File.exist?(path) || File.symlink?(path)
521
+ end
522
+
523
+ def tree_bytes(root)
524
+ Dir[File.join(root, "**", "*")].sum { |path| File.file?(path) ? File.size(path) : 0 }
525
+ end
526
+ end
527
+ end
data/lib/zui.rb CHANGED
@@ -14,14 +14,17 @@ require_relative "zui/application"
14
14
  require_relative "zui/source_bundle"
15
15
  require_relative "zui/platform"
16
16
  require_relative "zui/runtime"
17
+ require_relative "zui/tree_shaker"
18
+ require_relative "zui/dist_config"
17
19
  require_relative "zui/client"
18
20
  require_relative "zui/host"
19
21
  require_relative "zui/runner"
20
22
  require_relative "zui/generator"
21
23
  require_relative "zui/distribution"
24
+ require_relative "zui/dist_packager"
22
25
 
23
26
  module Zui
24
- VERSION = "0.0.6"
27
+ VERSION = "0.0.7"
25
28
  FRAMEWORK_ROOT = File.expand_path("..", __dir__)
26
29
 
27
30
  def self.app(&definition)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Moussa Ali
@@ -326,6 +326,8 @@ files:
326
326
  - lib/zui/command.rb
327
327
  - lib/zui/component_registry.rb
328
328
  - lib/zui/components.rb
329
+ - lib/zui/dist_config.rb
330
+ - lib/zui/dist_packager.rb
329
331
  - lib/zui/distribution.rb
330
332
  - lib/zui/generator.rb
331
333
  - lib/zui/host.rb
@@ -337,6 +339,7 @@ files:
337
339
  - lib/zui/scheduler.rb
338
340
  - lib/zui/source_bundle.rb
339
341
  - lib/zui/state_store.rb
342
+ - lib/zui/tree_shaker.rb
340
343
  - lib/zui/value.rb
341
344
  homepage: https://github.com/AdamMusa/zui
342
345
  licenses: