Skip to content

Commit cf8535e

Browse files
Support selecting component package files. (#74)
1 parent 90f98fa commit cf8535e

6 files changed

Lines changed: 308 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: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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. Package contents are copied from `dist` when it exists, otherwise from the package root.
12+
#
13+
# By default, the complete source directory is installed. Projects can limit an individual package to a set of files using `utopia.components` in their `package.json` file.
14+
class Components
15+
# Initialize a component installer for the given project root.
16+
#
17+
# @parameter root [String | Pathname] The project root directory.
18+
def initialize(root)
19+
@root = Pathname.new(root)
20+
@package_root = @root + "node_modules"
21+
22+
# This is a legacy path:
23+
unless @package_root.directory?
24+
@package_root = @root + "lib/components"
25+
end
26+
27+
@install_root = @root + "public/_components"
28+
@configuration = load_configuration
29+
end
30+
31+
# @attribute [Pathname] The directory containing the installed JavaScript packages.
32+
attr :package_root
33+
34+
# Update the specified packages in the public components directory.
35+
#
36+
# @parameter package_names [Array(String)] The production package names to install.
37+
def update(package_names)
38+
expand_package_paths(@package_root).each do |package_path|
39+
package_name = package_path.relative_path_from(@package_root).to_s
40+
41+
if package_names.include?(package_name)
42+
install(package_name, package_path)
43+
end
44+
end
45+
end
46+
47+
private
48+
49+
# Load the optional per-package installation rules. A missing `package.json`, or a file without `utopia.components`, preserves the default behaviour of copying complete packages.
50+
# @returns [Hash] The per-package installation rules.
51+
def load_configuration
52+
package_path = @root + "package.json"
53+
54+
unless package_path.file?
55+
return {}
56+
end
57+
58+
configuration = JSON.parse(package_path.read).dig("utopia", "components") || {}
59+
60+
unless configuration.is_a?(Hash)
61+
raise ArgumentError, "utopia.components must be an object!"
62+
end
63+
64+
return configuration
65+
end
66+
67+
# Install one package. Distribution directories are preferred because they generally contain the browser-ready form of a package.
68+
# @parameter package_name [String] The package name relative to `node_modules`.
69+
# @parameter package_path [Pathname] The package source directory.
70+
def install(package_name, package_path)
71+
install_path = @install_root + package_name
72+
dist_path = package_path + "dist"
73+
74+
if dist_path.directory?
75+
source_path = dist_path
76+
else
77+
source_path = package_path
78+
end
79+
80+
configuration = @configuration[package_name]
81+
82+
if configuration
83+
install_selected(package_name, source_path, install_path, configuration)
84+
else
85+
FileUtils::Verbose.rm_rf(install_path)
86+
FileUtils::Verbose.mkpath(install_path.dirname)
87+
FileUtils::Verbose.cp_r(source_path, install_path)
88+
end
89+
end
90+
91+
# Install only the files matched by the configured include patterns. Every pattern is resolved before removing the existing installation, so an invalid configuration cannot leave a package partially installed or remove a previously working copy.
92+
# @parameter package_name [String] The package name relative to `node_modules`.
93+
# @parameter source_path [Pathname] The package source directory.
94+
# @parameter install_path [Pathname] The destination directory.
95+
# @parameter configuration [Hash] The package installation rules.
96+
def install_selected(package_name, source_path, install_path, configuration)
97+
unless configuration.is_a?(Hash)
98+
raise ArgumentError, "utopia.components.#{package_name}.include must be a non-empty array!"
99+
end
100+
101+
include_patterns = configuration["include"]
102+
103+
unless include_patterns.is_a?(Array) && include_patterns.any?
104+
raise ArgumentError, "utopia.components.#{package_name}.include must be a non-empty array!"
105+
end
106+
107+
paths = include_patterns.flat_map do |pattern|
108+
included_paths(package_name, source_path, pattern)
109+
end.uniq.sort
110+
111+
FileUtils::Verbose.rm_rf(install_path)
112+
113+
paths.each do |relative_path|
114+
source_file = source_path + relative_path
115+
install_file = install_path + relative_path
116+
117+
FileUtils::Verbose.mkpath(install_file.dirname)
118+
FileUtils::Verbose.cp(source_file, install_file)
119+
end
120+
end
121+
122+
# Expand one include pattern into files relative to the package source. Directories are excluded so each result can be copied independently.
123+
# @parameter package_name [String] The package name used in validation errors.
124+
# @parameter source_path [Pathname] The package source directory.
125+
# @parameter pattern [String] The include pattern to expand.
126+
# @returns [Array(String)] The matching file paths relative to the package source.
127+
def included_paths(package_name, source_path, pattern)
128+
unless pattern.is_a?(String) && relative_pattern?(pattern)
129+
raise ArgumentError, "Invalid include pattern for #{package_name}: #{pattern.inspect}"
130+
end
131+
132+
paths = Dir.glob(pattern, base: source_path.to_s).select do |relative_path|
133+
(source_path + relative_path).file?
134+
end
135+
136+
if paths.empty?
137+
raise ArgumentError, "Include pattern for #{package_name} matched no files: #{pattern.inspect}"
138+
end
139+
140+
return paths
141+
end
142+
143+
# Determine whether the pattern is contained within the package source. Absolute paths and parent traversal are rejected because they could otherwise copy arbitrary files from outside the package.
144+
# @parameter pattern [String] The include pattern to validate.
145+
# @returns [Boolean] Whether the pattern is relative and does not contain parent traversal.
146+
def relative_pattern?(pattern)
147+
path = Pathname.new(pattern)
148+
149+
if path.absolute?
150+
return false
151+
end
152+
153+
if path.each_filename.any?{|component| component == ".."}
154+
return false
155+
end
156+
157+
return true
158+
end
159+
160+
# Enumerate packages in `node_modules`, descending through scoped package directories such as `@socketry` while preserving their scoped names.
161+
# @parameter root [Pathname] The directory to enumerate.
162+
# @parameter into [Array(Pathname)] The array into which package paths are appended.
163+
# @returns [Array(Pathname)] The discovered package directories.
164+
def expand_package_paths(root, into = [])
165+
root.children.select(&:directory?).each do |path|
166+
basename = path.basename.to_s
167+
168+
# Handle organisation sub-directories which start with an '@' symbol:
169+
if basename.start_with?("@")
170+
expand_package_paths(path, into)
171+
else
172+
into << path
173+
end
174+
end
175+
176+
return into
177+
end
178+
end
179+
end

0 commit comments

Comments
 (0)