Skip to content

Commit 406c582

Browse files
Support selecting component package files
1 parent 90f98fa commit 406c582

6 files changed

Lines changed: 262 additions & 89 deletions

File tree

bake/utopia/components.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
NPM = ENV["NPM"] || "npm"
7+
8+
# Update public components from production JavaScript packages.
9+
#
10+
# Packages are copied from their `dist` directory when present, or otherwise
11+
# from the package root. The `utopia.components` section of `package.json` can
12+
# specify per-package `include` patterns to select only required files.
13+
#
14+
# @parameter root [String] The project root directory.
15+
def update(root: context.root)
16+
require "json"
17+
require "open3"
18+
require "utopia/components"
19+
20+
components = Utopia::Components.new(root)
21+
production_packages = fetch_production_packages(components.package_root)
22+
23+
components.update(production_packages)
24+
end
25+
26+
private
27+
28+
def fetch_production_packages(package_root)
29+
stdout, _status = Open3.capture2(NPM, "ls", "--production", "--json", chdir: package_root.to_s)
30+
json = JSON.parse(stdout)
31+
32+
flatten_package_dependencies(json).sort.uniq
33+
end
34+
35+
def flatten_package_dependencies(json, into = [])
36+
if json["dependencies"]
37+
json["dependencies"].each do |name, details|
38+
into << name
39+
flatten_package_dependencies(details, into)
40+
end
41+
end
42+
43+
return into
44+
end

bake/utopia/node.rb

Lines changed: 0 additions & 87 deletions
This file was deleted.

context/integrating-with-javascript.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ $ npm install jquery
1717
Copy the distribution files to `public/_components`:
1818

1919
```bash
20-
$ bundle exec bake utopia:node:update
20+
$ bundle exec bake utopia:components:update
2121
```
2222

2323
This will copy the library's distribution files (typically from `node_modules/*/dist/`) to your `public/_components/` directory, making them available for local serving.

guides/integrating-with-javascript/readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ $ npm install jquery
1717
Copy the distribution files to `public/_components`:
1818

1919
```bash
20-
$ bundle exec bake utopia:node:update
20+
$ bundle exec bake utopia:components:update
2121
```
2222

2323
This will copy the library's distribution files (typically from `node_modules/*/dist/`) to your `public/_components/` directory, making them available for local serving.

lib/utopia/components.rb

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "fileutils"
7+
require "json"
8+
require "pathname"
9+
10+
module Utopia
11+
# Installs JavaScript packages into the public components directory.
12+
class Components
13+
# Initialize a component installer for the given project root.
14+
#
15+
# @parameter root [String | Pathname] The project root directory.
16+
def initialize(root)
17+
@root = Pathname.new(root)
18+
@package_root = @root + "node_modules"
19+
20+
# This is a legacy path:
21+
unless @package_root.directory?
22+
@package_root = @root + "lib/components"
23+
end
24+
25+
@install_root = @root + "public/_components"
26+
@configuration = load_configuration
27+
end
28+
29+
attr :package_root
30+
31+
# Update the specified packages in the public components directory.
32+
#
33+
# @parameter package_names [Array(String)] The production package names to install.
34+
def update(package_names)
35+
expand_package_paths(@package_root).each do |package_path|
36+
package_name = package_path.relative_path_from(@package_root).to_s
37+
38+
if package_names.include?(package_name)
39+
install(package_name, package_path)
40+
end
41+
end
42+
end
43+
44+
private
45+
46+
def load_configuration
47+
package_path = @root + "package.json"
48+
return {} unless package_path.file?
49+
50+
configuration = JSON.parse(package_path.read).dig("utopia", "components") || {}
51+
52+
unless configuration.is_a?(Hash)
53+
raise ArgumentError, "utopia.components must be an object!"
54+
end
55+
56+
return configuration
57+
end
58+
59+
def install(package_name, package_path)
60+
install_path = @install_root + package_name
61+
dist_path = package_path + "dist"
62+
source_path = dist_path.directory? ? dist_path : package_path
63+
64+
if configuration = @configuration[package_name]
65+
install_selected(package_name, source_path, install_path, configuration)
66+
else
67+
FileUtils::Verbose.rm_rf(install_path)
68+
FileUtils::Verbose.mkpath(install_path.dirname)
69+
FileUtils::Verbose.cp_r(source_path, install_path)
70+
end
71+
end
72+
73+
def install_selected(package_name, source_path, install_path, configuration)
74+
unless configuration.is_a?(Hash) && configuration["include"].is_a?(Array) && configuration["include"].any?
75+
raise ArgumentError, "utopia.components.#{package_name}.include must be a non-empty array!"
76+
end
77+
78+
paths = configuration["include"].flat_map do |pattern|
79+
included_paths(package_name, source_path, pattern)
80+
end.uniq.sort
81+
82+
FileUtils::Verbose.rm_rf(install_path)
83+
84+
paths.each do |relative_path|
85+
source_file = source_path + relative_path
86+
install_file = install_path + relative_path
87+
88+
FileUtils::Verbose.mkpath(install_file.dirname)
89+
FileUtils::Verbose.cp(source_file, install_file)
90+
end
91+
end
92+
93+
def included_paths(package_name, source_path, pattern)
94+
unless pattern.is_a?(String) && relative_pattern?(pattern)
95+
raise ArgumentError, "Invalid include pattern for #{package_name}: #{pattern.inspect}"
96+
end
97+
98+
paths = Dir.glob(pattern, base: source_path.to_s).select do |relative_path|
99+
(source_path + relative_path).file?
100+
end
101+
102+
if paths.empty?
103+
raise ArgumentError, "Include pattern for #{package_name} matched no files: #{pattern.inspect}"
104+
end
105+
106+
return paths
107+
end
108+
109+
def relative_pattern?(pattern)
110+
path = Pathname.new(pattern)
111+
112+
return false if path.absolute?
113+
return false if path.each_filename.any?{|component| component == ".."}
114+
115+
return true
116+
end
117+
118+
def expand_package_paths(root, into = [])
119+
root.children.select(&:directory?).each do |path|
120+
basename = path.basename.to_s
121+
122+
# Handle organisation sub-directories which start with an '@' symbol:
123+
if basename.start_with?("@")
124+
expand_package_paths(path, into)
125+
else
126+
into << path
127+
end
128+
end
129+
130+
return into
131+
end
132+
end
133+
end

test/utopia/components.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "fileutils"
7+
require "json"
8+
require "tmpdir"
9+
10+
require "utopia/components"
11+
12+
describe Utopia::Components do
13+
def write(path, content)
14+
FileUtils.mkdir_p(File.dirname(path))
15+
File.write(path, content)
16+
end
17+
18+
it "copies selected files from a package distribution" do
19+
Dir.mktmpdir do |root|
20+
package = File.join(root, "node_modules/mermaid/dist")
21+
write(File.join(package, "mermaid.esm.min.mjs"), "entry")
22+
write(File.join(package, "chunks/mermaid.esm.min/diagram.mjs"), "chunk")
23+
write(File.join(package, "chunks/mermaid.esm.min/diagram.mjs.map"), "map")
24+
write(File.join(package, "mermaid.js"), "unused")
25+
26+
write(File.join(root, "public/_components/mermaid/stale.mjs"), "stale")
27+
write(File.join(root, "package.json"), JSON.generate(
28+
"utopia" => {
29+
"components" => {
30+
"mermaid" => {
31+
"include" => [
32+
"mermaid.esm.min.mjs",
33+
"chunks/mermaid.esm.min/**/*.mjs",
34+
],
35+
},
36+
},
37+
},
38+
))
39+
40+
subject.new(root).update(["mermaid"])
41+
install = File.join(root, "public/_components/mermaid")
42+
43+
expect(File.read(File.join(install, "mermaid.esm.min.mjs"))).to be == "entry"
44+
expect(File.read(File.join(install, "chunks/mermaid.esm.min/diagram.mjs"))).to be == "chunk"
45+
expect(File).not.to be(:exist?, File.join(install, "chunks/mermaid.esm.min/diagram.mjs.map"))
46+
expect(File).not.to be(:exist?, File.join(install, "mermaid.js"))
47+
expect(File).not.to be(:exist?, File.join(install, "stale.mjs"))
48+
end
49+
end
50+
51+
it "copies unconfigured scoped packages using the existing behavior" do
52+
Dir.mktmpdir do |root|
53+
write(File.join(root, "package.json"), "{}")
54+
write(File.join(root, "node_modules/@socketry/syntax/Syntax.js"), "syntax")
55+
56+
subject.new(root).update(["@socketry/syntax"])
57+
58+
installed = File.join(root, "public/_components/@socketry/syntax/Syntax.js")
59+
expect(File.read(installed)).to be == "syntax"
60+
end
61+
end
62+
63+
it "validates patterns before removing existing components" do
64+
Dir.mktmpdir do |root|
65+
write(File.join(root, "node_modules/mermaid/dist/mermaid.esm.min.mjs"), "entry")
66+
write(File.join(root, "public/_components/mermaid/existing.mjs"), "existing")
67+
write(File.join(root, "package.json"), JSON.generate(
68+
"utopia" => {
69+
"components" => {
70+
"mermaid" => {"include" => ["missing/**/*.mjs"]},
71+
},
72+
},
73+
))
74+
75+
expect do
76+
subject.new(root).update(["mermaid"])
77+
end.to raise_exception(ArgumentError, message: be =~ /matched no files/)
78+
79+
existing = File.join(root, "public/_components/mermaid/existing.mjs")
80+
expect(File.read(existing)).to be == "existing"
81+
end
82+
end
83+
end

0 commit comments

Comments
 (0)