class Components

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.

Definitions

def initialize(root)

Initialize a component installer for the given project root.

Signature

parameter root String | Pathname

The project root directory.

Implementation

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

attr :package_root

Signature

attribute Pathname

The directory containing the installed JavaScript packages.

def update(package_names)

Update the specified packages in the public components directory.

Signature

parameter package_names Array(String)

The production package names to install.

Implementation

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

def load_configuration

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.

Signature

returns Hash

The per-package installation rules.

Implementation

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

def install(package_name, package_path)

Install one package. Distribution directories are preferred because they generally contain the browser-ready form of a package.

Signature

parameter package_name String

The package name relative to node_modules.

parameter package_path Pathname

The package source directory.

Implementation

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

def install_selected(package_name, source_path, install_path, configuration)

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.

Signature

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.

Implementation

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

def included_paths(package_name, source_path, pattern)

Expand one include pattern into files relative to the package source. Directories are excluded so each result can be copied independently.

Signature

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.

Implementation

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

def relative_pattern?(pattern)

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.

Signature

parameter pattern String

The include pattern to validate.

returns Boolean

Whether the pattern is relative and does not contain parent traversal.

Implementation

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

def expand_package_paths(root, into = [])

Enumerate packages in node_modules, descending through scoped package directories such as @socketry while preserving their scoped names.

Signature

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.

Implementation

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