diff --git a/bake/utopia/components.rb b/bake/utopia/components.rb
deleted file mode 100644
index 3dace629..00000000
--- a/bake/utopia/components.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-# Released under the MIT License.
-# Copyright, 2026, by Samuel Williams.
-
-NPM = ENV["NPM"] || "npm"
-
-# Update public components from production JavaScript packages.
-#
-# Packages are copied from their `dist` directory when present, or otherwise
-# from the package root. The `utopia.components` section of `package.json` can
-# specify per-package `include` patterns to select only required files.
-#
-# @parameter root [String] The project root directory.
-def update(root: context.root)
- require "json"
- require "open3"
- require "utopia/components"
-
- components = Utopia::Components.new(root)
- production_packages = fetch_production_packages(components.package_root)
-
- components.update(production_packages)
-end
-
-private
-
-def fetch_production_packages(package_root)
- stdout, _status = Open3.capture2(NPM, "ls", "--production", "--json", chdir: package_root.to_s)
- json = JSON.parse(stdout)
-
- flatten_package_dependencies(json).sort.uniq
-end
-
-def flatten_package_dependencies(json, into = [])
- if json["dependencies"]
- json["dependencies"].each do |name, details|
- into << name
- flatten_package_dependencies(details, into)
- end
- end
-
- return into
-end
diff --git a/context/integrating-with-javascript.md b/context/integrating-with-javascript.md
index cb869ad8..30add821 100644
--- a/context/integrating-with-javascript.md
+++ b/context/integrating-with-javascript.md
@@ -8,20 +8,45 @@ Import maps provide a modern way to manage JavaScript module dependencies. Utopi
### Installing JavaScript Libraries
-First, install the library using npm:
+Declare browser libraries as production dependencies in `package.json`. The `bake-node.packages` section selects the files that should be served and assigns their browser import names:
+
+```json
+{
+ "private": true,
+ "dependencies": {
+ "@socketry/syntax": "^0.6.1"
+ },
+ "bake-node": {
+ "packages": {
+ "@socketry/syntax": {
+ "include": [
+ "Syntax.js"
+ ],
+ "imports": {
+ "@socketry/syntax": "Syntax.js"
+ }
+ }
+ }
+ }
+}
+```
+
+Install the dependencies using the configured package manager, then generate the browser-facing package projection:
```bash
-$ npm install jquery
+$ bundle exec bake node:install
+$ bundle exec bake node:packages:static
```
-Copy the distribution files to `public/_components`:
+This installs dependencies into `node_modules/` and copies only the selected browser files into `public/_components/`. Treat both directories as generated projections rather than authored source.
+
+Use an immutable installation and verify the checked-in projection in CI:
```bash
-$ bundle exec bake utopia:components:update
+$ bundle exec bake node:install frozen=true
+$ bundle exec bake node:packages:check
```
-This will copy the library's distribution files (typically from `node_modules/*/dist/`) to your `public/_components/` directory, making them available for local serving.
-
### Creating the Import Map
Create a global import map in `lib/my_website/import_map.rb`:
@@ -30,12 +55,12 @@ Create a global import map in `lib/my_website/import_map.rb`:
require "utopia/import_map"
module MyWebsite
- IMPORT_MAP = Utopia::ImportMap.build(base: "/_components/") do |map|
- map.import("jquery", "./jquery/jquery.js")
- end
+ IMPORT_MAP = Utopia::ImportMap.load_manifest("public/_components")
end
```
+This loads the generated package mappings directly, so the browser import map remains synchronized with `package.json`.
+
Then load this in `lib/my_website.rb`:
```ruby
@@ -64,15 +89,16 @@ Once the import map is set up, you can import and use the library in your script
```xrb
```
+Inspect the generated import map with `bundle exec bake node:importmap:show` when debugging package resolution.
+
+See the [Bake Node documentation](https://socketry.github.io/bake-node/) for workspace packages, package selection, and alternative package managers.
### Advanced Import Map Features
diff --git a/guides/integrating-with-javascript/readme.md b/guides/integrating-with-javascript/readme.md
index cb869ad8..30add821 100644
--- a/guides/integrating-with-javascript/readme.md
+++ b/guides/integrating-with-javascript/readme.md
@@ -8,20 +8,45 @@ Import maps provide a modern way to manage JavaScript module dependencies. Utopi
### Installing JavaScript Libraries
-First, install the library using npm:
+Declare browser libraries as production dependencies in `package.json`. The `bake-node.packages` section selects the files that should be served and assigns their browser import names:
+
+```json
+{
+ "private": true,
+ "dependencies": {
+ "@socketry/syntax": "^0.6.1"
+ },
+ "bake-node": {
+ "packages": {
+ "@socketry/syntax": {
+ "include": [
+ "Syntax.js"
+ ],
+ "imports": {
+ "@socketry/syntax": "Syntax.js"
+ }
+ }
+ }
+ }
+}
+```
+
+Install the dependencies using the configured package manager, then generate the browser-facing package projection:
```bash
-$ npm install jquery
+$ bundle exec bake node:install
+$ bundle exec bake node:packages:static
```
-Copy the distribution files to `public/_components`:
+This installs dependencies into `node_modules/` and copies only the selected browser files into `public/_components/`. Treat both directories as generated projections rather than authored source.
+
+Use an immutable installation and verify the checked-in projection in CI:
```bash
-$ bundle exec bake utopia:components:update
+$ bundle exec bake node:install frozen=true
+$ bundle exec bake node:packages:check
```
-This will copy the library's distribution files (typically from `node_modules/*/dist/`) to your `public/_components/` directory, making them available for local serving.
-
### Creating the Import Map
Create a global import map in `lib/my_website/import_map.rb`:
@@ -30,12 +55,12 @@ Create a global import map in `lib/my_website/import_map.rb`:
require "utopia/import_map"
module MyWebsite
- IMPORT_MAP = Utopia::ImportMap.build(base: "/_components/") do |map|
- map.import("jquery", "./jquery/jquery.js")
- end
+ IMPORT_MAP = Utopia::ImportMap.load_manifest("public/_components")
end
```
+This loads the generated package mappings directly, so the browser import map remains synchronized with `package.json`.
+
Then load this in `lib/my_website.rb`:
```ruby
@@ -64,15 +89,16 @@ Once the import map is set up, you can import and use the library in your script
```xrb
```
+Inspect the generated import map with `bundle exec bake node:importmap:show` when debugging package resolution.
+
+See the [Bake Node documentation](https://socketry.github.io/bake-node/) for workspace packages, package selection, and alternative package managers.
### Advanced Import Map Features
diff --git a/lib/utopia/components.rb b/lib/utopia/components.rb
deleted file mode 100644
index caf65d61..00000000
--- a/lib/utopia/components.rb
+++ /dev/null
@@ -1,174 +0,0 @@
-# frozen_string_literal: true
-
-# Released under the MIT License.
-# Copyright, 2026, by Samuel Williams.
-
-require "fileutils"
-require "json"
-require "pathname"
-
-module Utopia
- # Installs JavaScript packages from `node_modules` into the public components directory. Package contents are copied from `dist` when it exists, otherwise from the package root.
- #
- # 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.
- class Components
- # Initialize a component installer for the given project root.
- #
- # @parameter root [String | Pathname] The project root directory.
- def initialize(root)
- @root = Pathname.new(root)
- @package_root = @root + "node_modules"
-
- @install_root = @root + "public/_components"
- @configuration = load_configuration
- end
-
- # @attribute [Pathname] The directory containing the installed JavaScript packages.
- attr :package_root
-
- # Update the specified packages in the public components directory.
- #
- # @parameter package_names [Array(String)] The production package names to install.
- def update(package_names)
- expand_package_paths(@package_root).each do |package_path|
- package_name = package_path.relative_path_from(@package_root).to_s
-
- if package_names.include?(package_name)
- install(package_name, package_path)
- end
- end
- end
-
- private
-
- # 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.
- # @returns [Hash] The per-package installation rules.
- def load_configuration
- package_path = @root + "package.json"
-
- unless package_path.file?
- return {}
- end
-
- configuration = JSON.parse(package_path.read).dig("utopia", "components") || {}
-
- unless configuration.is_a?(Hash)
- raise ArgumentError, "utopia.components must be an object!"
- end
-
- return configuration
- end
-
- # Install one package. Distribution directories are preferred because they generally contain the browser-ready form of a package.
- # @parameter package_name [String] The package name relative to `node_modules`.
- # @parameter package_path [Pathname] The package source directory.
- def install(package_name, package_path)
- install_path = @install_root + package_name
- dist_path = package_path + "dist"
-
- if dist_path.directory?
- source_path = dist_path
- else
- source_path = package_path
- end
-
- configuration = @configuration[package_name]
-
- if configuration
- install_selected(package_name, source_path, install_path, configuration)
- else
- FileUtils::Verbose.rm_rf(install_path)
- FileUtils::Verbose.mkpath(install_path.dirname)
- FileUtils::Verbose.cp_r(source_path, install_path)
- end
- end
-
- # 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.
- # @parameter package_name [String] The package name relative to `node_modules`.
- # @parameter source_path [Pathname] The package source directory.
- # @parameter install_path [Pathname] The destination directory.
- # @parameter configuration [Hash] The package installation rules.
- def install_selected(package_name, source_path, install_path, configuration)
- unless configuration.is_a?(Hash)
- raise ArgumentError, "utopia.components.#{package_name}.include must be a non-empty array!"
- end
-
- include_patterns = configuration["include"]
-
- unless include_patterns.is_a?(Array) && include_patterns.any?
- raise ArgumentError, "utopia.components.#{package_name}.include must be a non-empty array!"
- end
-
- paths = include_patterns.flat_map do |pattern|
- included_paths(package_name, source_path, pattern)
- end.uniq.sort
-
- FileUtils::Verbose.rm_rf(install_path)
-
- paths.each do |relative_path|
- source_file = source_path + relative_path
- install_file = install_path + relative_path
-
- FileUtils::Verbose.mkpath(install_file.dirname)
- FileUtils::Verbose.cp(source_file, install_file)
- end
- end
-
- # Expand one include pattern into files relative to the package source. Directories are excluded so each result can be copied independently.
- # @parameter package_name [String] The package name used in validation errors.
- # @parameter source_path [Pathname] The package source directory.
- # @parameter pattern [String] The include pattern to expand.
- # @returns [Array(String)] The matching file paths relative to the package source.
- def included_paths(package_name, source_path, pattern)
- unless pattern.is_a?(String) && relative_pattern?(pattern)
- raise ArgumentError, "Invalid include pattern for #{package_name}: #{pattern.inspect}"
- end
-
- paths = Dir.glob(pattern, base: source_path.to_s).select do |relative_path|
- (source_path + relative_path).file?
- end
-
- if paths.empty?
- raise ArgumentError, "Include pattern for #{package_name} matched no files: #{pattern.inspect}"
- end
-
- return paths
- end
-
- # 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.
- # @parameter pattern [String] The include pattern to validate.
- # @returns [Boolean] Whether the pattern is relative and does not contain parent traversal.
- def relative_pattern?(pattern)
- path = Pathname.new(pattern)
-
- if path.absolute?
- return false
- end
-
- if path.each_filename.any?{|component| component == ".."}
- return false
- end
-
- return true
- end
-
- # Enumerate packages in `node_modules`, descending through scoped package directories such as `@socketry` while preserving their scoped names.
- # @parameter root [Pathname] The directory to enumerate.
- # @parameter into [Array(Pathname)] The array into which package paths are appended.
- # @returns [Array(Pathname)] The discovered package directories.
- def expand_package_paths(root, into = [])
- root.children.select(&:directory?).each do |path|
- basename = path.basename.to_s
-
- # Handle organisation sub-directories which start with an '@' symbol:
- if basename.start_with?("@")
- expand_package_paths(path, into)
- else
- into << path
- end
- end
-
- return into
- end
- end
-end
diff --git a/lib/utopia/import_map.rb b/lib/utopia/import_map.rb
index ab52969a..b6e5bc8d 100644
--- a/lib/utopia/import_map.rb
+++ b/lib/utopia/import_map.rb
@@ -6,6 +6,7 @@
require "json"
require "xrb"
require "protocol/url"
+require "bake/node/manifest"
module Utopia
# Represents an import map for JavaScript modules with support for URI and relative path resolution.
@@ -55,6 +56,19 @@ module Utopia
#
# puts page_map.to_html
class ImportMap
+ # Load the import mappings from a Bake Node static package manifest.
+ # @parameter root [String | Pathname] The static package output directory.
+ # @returns [ImportMap] A frozen import map using the manifest's public base URL.
+ def self.load_manifest(root)
+ manifest = Bake::Node::Manifest.load(root)
+ base = Protocol::URL[manifest.data.fetch("base")]
+ imports = manifest.import_map.fetch("imports").transform_values do |value|
+ Protocol::URL[value].relative_to(base).to_s
+ end
+
+ return self.new(imports, base: base).freeze
+ end
+
# Builder class for constructing import maps with scoped base URIs.
#
# The builder supports nested `with(base:)` blocks where each base is resolved
diff --git a/readme.md b/readme.md
index 854c777e..3f2c6d67 100644
--- a/readme.md
+++ b/readme.md
@@ -31,6 +31,10 @@ Please see the [project documentation](https://socketry.github.io/utopia/) for m
Please see the [project releases](https://socketry.github.io/utopia/releases/index) for all releases.
+### Unreleased
+
+ - [JavaScript Packages](https://socketry.github.io/utopia/releases/index#javascript-packages)
+
### v3.0.5
- **Breaking** Remove support for JavaScript packages installed in `lib/components`; use `node_modules` instead.
diff --git a/releases.md b/releases.md
index 2eec1024..b2adf902 100644
--- a/releases.md
+++ b/releases.md
@@ -1,5 +1,13 @@
# Releases
+## Unreleased
+
+### JavaScript Packages
+
+Utopia now depends on `bake-node` for JavaScript dependency installation and static package projection. `Utopia::Components` and `utopia:components:update` have been removed. Replace the old task with `bundle exec bake node:packages:static`, and migrate package selection from `utopia.components` to `bake-node.packages` in `package.json`.
+
+Use `Utopia::ImportMap.load_manifest("public/_components")` to load the generated browser import mappings directly from the Bake Node manifest.
+
## v3.0.5
- **Breaking** Remove support for JavaScript packages installed in `lib/components`; use `node_modules` instead.
diff --git a/test/utopia/components.rb b/test/utopia/components.rb
deleted file mode 100644
index 92e272e3..00000000
--- a/test/utopia/components.rb
+++ /dev/null
@@ -1,133 +0,0 @@
-# frozen_string_literal: true
-
-# Released under the MIT License.
-# Copyright, 2026, by Samuel Williams.
-
-require "fileutils"
-require "json"
-require "sus/fixtures/temporary_directory_context"
-
-require "utopia/components"
-
-describe Utopia::Components do
- include Sus::Fixtures::TemporaryDirectoryContext
-
- def write(path, content)
- FileUtils.mkdir_p(File.dirname(path))
- File.write(path, content)
- end
-
- it "copies selected files from a package distribution" do
- package = File.join(root, "node_modules/mermaid/dist")
- write(File.join(package, "mermaid.esm.min.mjs"), "entry")
- write(File.join(package, "chunks/mermaid.esm.min/diagram.mjs"), "chunk")
- write(File.join(package, "chunks/mermaid.esm.min/diagram.mjs.map"), "map")
- write(File.join(package, "mermaid.js"), "unused")
-
- write(File.join(root, "public/_components/mermaid/stale.mjs"), "stale")
- write(File.join(root, "package.json"), JSON.generate(
- "utopia" => {
- "components" => {
- "mermaid" => {
- "include" => [
- "mermaid.esm.min.mjs",
- "chunks/mermaid.esm.min/**/*.mjs",
- ],
- },
- },
- },
- ))
-
- subject.new(root).update(["mermaid"])
- install = File.join(root, "public/_components/mermaid")
-
- expect(File.read(File.join(install, "mermaid.esm.min.mjs"))).to be == "entry"
- expect(File.read(File.join(install, "chunks/mermaid.esm.min/diagram.mjs"))).to be == "chunk"
- expect(File).not.to be(:exist?, File.join(install, "chunks/mermaid.esm.min/diagram.mjs.map"))
- expect(File).not.to be(:exist?, File.join(install, "mermaid.js"))
- expect(File).not.to be(:exist?, File.join(install, "stale.mjs"))
- end
-
- it "copies unconfigured scoped packages using the existing behavior" do
- write(File.join(root, "node_modules/@socketry/syntax/Syntax.js"), "syntax")
-
- subject.new(root).update(["@socketry/syntax"])
-
- installed = File.join(root, "public/_components/@socketry/syntax/Syntax.js")
- expect(File.read(installed)).to be == "syntax"
- end
-
- it "uses node_modules as the package directory" do
- components = subject.new(root)
-
- expect(components.package_root).to be == Pathname.new(root) + "node_modules"
- end
-
- it "requires component configuration to be an object" do
- write(File.join(root, "package.json"), JSON.generate(
- "utopia" => {"components" => []},
- ))
-
- expect do
- subject.new(root)
- end.to raise_exception(ArgumentError, message: be =~ /components must be an object/)
- end
-
- it "requires package configuration to be an object" do
- write(File.join(root, "node_modules/example/example.js"), "example")
- write(File.join(root, "package.json"), JSON.generate(
- "utopia" => {"components" => {"example" => []}},
- ))
-
- expect do
- subject.new(root).update(["example"])
- end.to raise_exception(ArgumentError, message: be =~ /include must be a non-empty array/)
- end
-
- it "requires package include patterns" do
- write(File.join(root, "node_modules/example/example.js"), "example")
- write(File.join(root, "package.json"), JSON.generate(
- "utopia" => {"components" => {"example" => {}}},
- ))
-
- expect do
- subject.new(root).update(["example"])
- end.to raise_exception(ArgumentError, message: be =~ /include must be a non-empty array/)
- end
-
- {
- "type" => 1,
- "absolute" => "/example.js",
- "parent" => "../example.js",
- }.each do |name, pattern|
- it "rejects invalid include patterns", unique: name do
- write(File.join(root, "node_modules/example/example.js"), "example")
- write(File.join(root, "package.json"), JSON.generate(
- "utopia" => {"components" => {"example" => {"include" => [pattern]}}},
- ))
-
- expect do
- subject.new(root).update(["example"])
- end.to raise_exception(ArgumentError, message: be =~ /Invalid include pattern/)
- end
- end
-
- it "validates patterns before removing existing components" do
- write(File.join(root, "node_modules/mermaid/dist/mermaid.esm.min.mjs"), "entry")
- write(File.join(root, "public/_components/mermaid/existing.mjs"), "existing")
- write(File.join(root, "package.json"), JSON.generate(
- "utopia" => {
- "components" => {
- "mermaid" => {"include" => ["missing/**/*.mjs"]},
- },
- },
- ))
-
- expect do
- subject.new(root).update(["mermaid"])
- end.to raise_exception(ArgumentError, message: be =~ /matched no files/)
-
- existing = File.join(root, "public/_components/mermaid/existing.mjs")
- expect(File.read(existing)).to be == "existing"
- end
-end
diff --git a/test/utopia/import_map.rb b/test/utopia/import_map.rb
index fbea0746..a48021bd 100644
--- a/test/utopia/import_map.rb
+++ b/test/utopia/import_map.rb
@@ -3,9 +3,33 @@
# Released under the MIT License.
# Copyright, 2025, by Samuel Williams.
+require "tmpdir"
+
require "utopia/import_map"
describe Utopia::ImportMap do
+ with ".load_manifest" do
+ it "loads generated imports while preserving relative rendering" do
+ Dir.mktmpdir do |root|
+ Bake::Node::Manifest.build(
+ base: "/_components/",
+ imports: {
+ "example" => "/_components/example/example.js",
+ "external" => "https://cdn.example.com/external.js",
+ },
+ packages: {},
+ ).write(root)
+
+ import_map = subject.load_manifest(root)
+
+ expect(import_map).to be(:frozen?)
+ expect(import_map.as_json.dig("imports", "example")).to be == "/_components/example/example.js"
+ expect(import_map.as_json.dig("imports", "external")).to be == "https://cdn.example.com/external.js"
+ expect(import_map.relative_to("/guides/setup/").as_json.dig("imports", "example")).to be == "../../_components/example/example.js"
+ end
+ end
+ end
+
with ".build" do
it "creates an import map with a block yielding map" do
import_map = subject.build do |map|
diff --git a/utopia.gemspec b/utopia.gemspec
index 8db5da26..e44aff41 100644
--- a/utopia.gemspec
+++ b/utopia.gemspec
@@ -26,6 +26,7 @@ Gem::Specification.new do |spec|
spec.required_ruby_version = ">= 3.3"
spec.add_dependency "bake", "~> 0.20"
+ spec.add_dependency "bake-node"
spec.add_dependency "concurrent-ruby", "~> 1.2"
spec.add_dependency "console", "~> 1.24"
spec.add_dependency "irb"