diff --git a/bake/utopia/components.rb b/bake/utopia/components.rb new file mode 100644 index 00000000..3dace629 --- /dev/null +++ b/bake/utopia/components.rb @@ -0,0 +1,44 @@ +# 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/bake/utopia/node.rb b/bake/utopia/node.rb deleted file mode 100644 index 822f1fdd..00000000 --- a/bake/utopia/node.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2016-2025, by Samuel Williams. - -NPM = ENV["NPM"] || "npm" - -def update - require "fileutils" - require "utopia/path" - - root = Pathname.new(context.root) - package_root = root + "node_modules" - - # This is a legacy path: - unless package_root.directory? - package_root = root + "lib/components" - end - - install_root = root + "public/_components" - - # Fetch only production dependencies using `npm ls --production` - production_packages = fetch_production_packages(package_root) - package_paths = expand_package_paths(package_root).select do |path| - package_name = path.relative_path_from(package_root).to_s - production_packages.include?(package_name) - end - - package_paths.each do |package_path| - package_directory = package_path.relative_path_from(package_root) - install_path = install_root + package_directory - - dist_path = package_path + "dist" - - FileUtils::Verbose.rm_rf(install_path) - FileUtils::Verbose.mkpath(install_path.dirname) - - # If a package has a dist directory, we only symlink that... otherwise we have to do the entire package, and hope that bower's ignore was setup correctly: - if dist_path.exist? - link_path = Utopia::Path.shortest_path(dist_path, install_path) - else - link_path = Utopia::Path.shortest_path(package_path, install_path) - end - - FileUtils::Verbose.cp_r File.expand_path(link_path, install_path), install_path - end -end - -private - -def fetch_production_packages(package_root) - require "json" - require "open3" - - 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 - -def expand_package_paths(root, into = []) - paths = root.children.select(&:directory?) - - paths.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 diff --git a/context/integrating-with-javascript.md b/context/integrating-with-javascript.md index 02ee6f59..cb869ad8 100644 --- a/context/integrating-with-javascript.md +++ b/context/integrating-with-javascript.md @@ -17,7 +17,7 @@ $ npm install jquery Copy the distribution files to `public/_components`: ```bash -$ bundle exec bake utopia:node:update +$ bundle exec bake utopia:components:update ``` This will copy the library's distribution files (typically from `node_modules/*/dist/`) to your `public/_components/` directory, making them available for local serving. diff --git a/guides/integrating-with-javascript/readme.md b/guides/integrating-with-javascript/readme.md index 02ee6f59..cb869ad8 100644 --- a/guides/integrating-with-javascript/readme.md +++ b/guides/integrating-with-javascript/readme.md @@ -17,7 +17,7 @@ $ npm install jquery Copy the distribution files to `public/_components`: ```bash -$ bundle exec bake utopia:node:update +$ bundle exec bake utopia:components:update ``` This will copy the library's distribution files (typically from `node_modules/*/dist/`) to your `public/_components/` directory, making them available for local serving. diff --git a/lib/utopia/components.rb b/lib/utopia/components.rb new file mode 100644 index 00000000..1677d2ef --- /dev/null +++ b/lib/utopia/components.rb @@ -0,0 +1,179 @@ +# 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 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" + + # This is a legacy path: + unless @package_root.directory? + @package_root = @root + "lib/components" + end + + @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/test/utopia/components.rb b/test/utopia/components.rb new file mode 100644 index 00000000..fd5cbd8b --- /dev/null +++ b/test/utopia/components.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "fileutils" +require "json" +require "tmpdir" + +require "utopia/components" + +describe Utopia::Components do + def write(path, content) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + it "copies selected files from a package distribution" do + Dir.mktmpdir do |root| + 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 + end + + it "copies unconfigured scoped packages using the existing behavior" do + Dir.mktmpdir do |root| + write(File.join(root, "package.json"), "{}") + 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 + end + + it "validates patterns before removing existing components" do + Dir.mktmpdir do |root| + 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 +end