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"