bake-node 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "digest"
7
+ require "json"
8
+ require "pathname"
9
+
10
+ require_relative "errors"
11
+
12
+ module Bake
13
+ module Node
14
+ # Records the content and import mappings of a static package projection.
15
+ class Manifest
16
+ FILENAME = ".bake-node.json"
17
+
18
+ # Build a deterministic manifest.
19
+ # @parameter base [String] The public URL prefix for static packages.
20
+ # @parameter imports [Hash(String, String)] The import-map entries.
21
+ # @parameter packages [Hash(String, Hash)] The installed package metadata.
22
+ # @returns [Manifest] The generated manifest.
23
+ def self.build(base:, imports:, packages:)
24
+ data = {
25
+ "format" => 1,
26
+ "base" => base,
27
+ "imports" => imports.sort.to_h,
28
+ "packages" => packages.sort.to_h,
29
+ }
30
+
31
+ data["digest"] = Digest::SHA256.hexdigest(JSON.generate(data))
32
+ new(data)
33
+ end
34
+
35
+ # Load a manifest from a static output directory.
36
+ # @parameter root [String | Pathname] The static output directory.
37
+ # @returns [Manifest] The loaded manifest.
38
+ # @raises [CheckError] If the manifest is missing or malformed.
39
+ def self.load(root)
40
+ path = Pathname.new(root) + FILENAME
41
+ new(JSON.parse(path.read))
42
+ rescue Errno::ENOENT
43
+ raise CheckError, "Static package manifest does not exist at #{path}!"
44
+ rescue JSON::ParserError => error
45
+ raise CheckError, "Could not parse #{path}: #{error.message}"
46
+ end
47
+
48
+ # Initialize a manifest with its serialized data.
49
+ # @parameter data [Hash] The manifest data.
50
+ def initialize(data)
51
+ @data = data
52
+ end
53
+
54
+ # @attribute [Hash] The serialized manifest data.
55
+ attr :data
56
+
57
+ # Write the manifest into a static output directory.
58
+ # @parameter root [String | Pathname] The static output directory.
59
+ # @returns [Integer] The number of bytes written.
60
+ def write(root)
61
+ path = Pathname.new(root) + FILENAME
62
+ path.write(JSON.pretty_generate(@data) + "\n")
63
+ end
64
+
65
+ # Extract the browser import map.
66
+ # @returns [Hash] An import-map object containing the configured imports.
67
+ def import_map
68
+ {"imports" => @data.fetch("imports", {})}
69
+ end
70
+
71
+ # Check whether every manifested file exists with the expected content.
72
+ # @parameter root [String | Pathname] The static output directory.
73
+ # @returns [Boolean] Whether the directory exactly matches the manifest.
74
+ def valid_tree?(root)
75
+ root = Pathname.new(root)
76
+ expected = []
77
+
78
+ @data.fetch("packages").each do |name, package|
79
+ package.fetch("files").each do |relative_path, digest|
80
+ path = root + name + relative_path
81
+ expected << path.relative_path_from(root).to_s
82
+
83
+ return false unless path.file?
84
+ return false unless Digest::SHA256.file(path).hexdigest == digest
85
+ end
86
+ end
87
+
88
+ actual = root.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map do |path|
89
+ path.relative_path_from(root).to_s
90
+ end
91
+ actual.delete(FILENAME)
92
+
93
+ actual.sort == expected.sort
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "pathname"
7
+
8
+ require_relative "errors"
9
+
10
+ module Bake
11
+ module Node
12
+ # Describes how one installed Node.js package is exposed as static files.
13
+ class Package
14
+ NAME_PATTERN = /\A(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+\z/i
15
+
16
+ # Initialize a package selection.
17
+ # @parameter name [String] The npm package name, including an optional scope.
18
+ # @parameter options [Hash] The package's source, include patterns and import mappings.
19
+ # @raises [ConfigurationError] If the package name or options are invalid.
20
+ def initialize(name, options = {})
21
+ unless name.is_a?(String) && NAME_PATTERN.match?(name)
22
+ raise ConfigurationError, "Invalid package name: #{name.inspect}!"
23
+ end
24
+
25
+ unless options.is_a?(Hash)
26
+ raise ConfigurationError, "Configuration for #{name} must be an object!"
27
+ end
28
+
29
+ @name = name
30
+ @source = options["source"]
31
+ @include_patterns = options["include"]
32
+ @imports = options.fetch("imports", {})
33
+
34
+ validate
35
+ end
36
+
37
+ # @attribute [String] The npm package name.
38
+ attr :name
39
+
40
+ # @attribute [String | Nil] The configured package-relative source directory.
41
+ attr :source
42
+
43
+ # @attribute [Array(String) | Nil] The package-relative file patterns to include.
44
+ attr :include_patterns
45
+
46
+ # @attribute [Hash(String, String)] The import specifiers exposed by the package.
47
+ attr :imports
48
+
49
+ private
50
+
51
+ def validate
52
+ validate_relative_path(@source, "source") if @source
53
+
54
+ if @include_patterns
55
+ unless @include_patterns.is_a?(Array) && @include_patterns.any?
56
+ raise ConfigurationError, "The include patterns for #{@name} must be a non-empty array!"
57
+ end
58
+
59
+ @include_patterns.each do |pattern|
60
+ validate_relative_path(pattern, "include pattern")
61
+ end
62
+ end
63
+
64
+ unless @imports.is_a?(Hash) && @imports.all?{|key, value| key.is_a?(String) && value.is_a?(String)}
65
+ raise ConfigurationError, "The imports for #{@name} must map strings to strings!"
66
+ end
67
+ end
68
+
69
+ def validate_relative_path(path, description)
70
+ unless path.is_a?(String) && !path.empty?
71
+ raise ConfigurationError, "The #{description} for #{@name} must be a non-empty string!"
72
+ end
73
+
74
+ pathname = Pathname.new(path)
75
+
76
+ if pathname.absolute? || pathname.each_filename.any?{|component| component == ".."}
77
+ raise ConfigurationError, "The #{description} for #{@name} must remain within the package: #{path.inspect}!"
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "errors"
7
+
8
+ module Bake
9
+ module Node
10
+ # Detects and invokes npm-compatible package managers without replacing them.
11
+ class PackageManager
12
+ LOCK_FILES = {
13
+ "package-lock.json" => "npm",
14
+ "npm-shrinkwrap.json" => "npm",
15
+ "pnpm-lock.yaml" => "pnpm",
16
+ "yarn.lock" => "yarn",
17
+ "bun.lock" => "bun",
18
+ "bun.lockb" => "bun",
19
+ }.freeze
20
+
21
+ SUPPORTED = ["npm", "pnpm", "yarn", "bun"].freeze
22
+
23
+ # Detect the package manager selected by a project.
24
+ # @parameter configuration [Configuration] The project configuration.
25
+ # @parameter environment [Hash] Environment variables used for explicit selection.
26
+ # @returns [PackageManager] The selected package-manager wrapper.
27
+ # @raises [ConfigurationError] If selection is ambiguous or unsupported.
28
+ def self.detect(configuration, environment: ENV)
29
+ if name = environment["NODE_PACKAGE_MANAGER"]
30
+ return new(configuration.root, name)
31
+ end
32
+
33
+ if declaration = configuration.package_json["packageManager"]
34
+ unless declaration.is_a?(String)
35
+ raise ConfigurationError, "packageManager must be a string!"
36
+ end
37
+
38
+ return new(configuration.root, declaration.split("@", 2).first)
39
+ end
40
+
41
+ managers = LOCK_FILES.filter_map do |path, manager|
42
+ manager if (configuration.root + path).file?
43
+ end.uniq
44
+
45
+ if managers.size > 1
46
+ raise ConfigurationError, "Multiple package manager lock files were found: #{managers.join(', ')}!"
47
+ end
48
+
49
+ new(configuration.root, managers.first || "npm")
50
+ end
51
+
52
+ # Initialize a package-manager wrapper.
53
+ # @parameter root [String | Pathname] The directory in which commands will run.
54
+ # @parameter name [String] The package-manager executable name.
55
+ # @raises [ConfigurationError] If the package manager is unsupported.
56
+ def initialize(root, name)
57
+ unless SUPPORTED.include?(name)
58
+ raise ConfigurationError, "Unsupported package manager: #{name.inspect}!"
59
+ end
60
+
61
+ @root = root
62
+ @name = name
63
+ end
64
+
65
+ # @attribute [String | Pathname] The directory in which commands run.
66
+ attr :root
67
+
68
+ # @attribute [String] The selected package-manager name.
69
+ attr :name
70
+
71
+ # Install the project's dependencies.
72
+ # @parameter frozen [Boolean] Whether the lock file must remain unchanged.
73
+ # @raises [Error] If the package-manager command fails.
74
+ def install(frozen: false)
75
+ execute(install_command(frozen: frozen))
76
+ end
77
+
78
+ # Run a package script.
79
+ # @parameter script [String] The script name from `package.json`.
80
+ # @raises [ArgumentError] If the script name is empty.
81
+ # @raises [Error] If the package-manager command fails.
82
+ def run(script)
83
+ unless script.is_a?(String) && !script.empty?
84
+ raise ArgumentError, "Script must be a non-empty string!"
85
+ end
86
+
87
+ execute([@name, "run", script])
88
+ end
89
+
90
+ # Construct the installation command for the selected package manager.
91
+ # @parameter frozen [Boolean] Whether the lock file must remain unchanged.
92
+ # @returns [Array(String)] The command and arguments to execute.
93
+ def install_command(frozen: false)
94
+ command = case @name
95
+ when "npm"
96
+ frozen ? ["npm", "ci"] : ["npm", "install"]
97
+ when "pnpm"
98
+ ["pnpm", "install"]
99
+ when "yarn"
100
+ ["yarn", "install"]
101
+ when "bun"
102
+ ["bun", "install"]
103
+ end
104
+
105
+ if frozen && @name != "npm"
106
+ command << (@name == "yarn" ? "--immutable" : "--frozen-lockfile")
107
+ end
108
+
109
+ command
110
+ end
111
+
112
+ private
113
+
114
+ def execute(command)
115
+ unless system(*command, chdir: @root.to_s)
116
+ raise Error, "Command failed: #{command.join(' ')}"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "digest"
7
+ require "fileutils"
8
+ require "json"
9
+ require "pathname"
10
+ require "tmpdir"
11
+
12
+ require_relative "errors"
13
+ require_relative "manifest"
14
+
15
+ module Bake
16
+ module Node
17
+ # Builds and validates deterministic static projections of installed packages.
18
+ class Static
19
+ EXCLUDED_COMPONENTS = [".git", "node_modules"].freeze
20
+
21
+ # Initialize a static package builder.
22
+ # @parameter configuration [Configuration] The project configuration.
23
+ # @parameter output [String | Nil] An optional project-relative output directory.
24
+ def initialize(configuration, output: nil)
25
+ @configuration = configuration
26
+ @output = configuration.output_path(output)
27
+ end
28
+
29
+ # @attribute [Configuration] The project configuration.
30
+ attr :configuration
31
+
32
+ # @attribute [Pathname] The static output directory.
33
+ attr :output
34
+
35
+ # Build and atomically replace the static package projection.
36
+ # @returns [Manifest] The generated manifest.
37
+ # @raises [PackageError] If an installed package cannot be materialized safely.
38
+ def update
39
+ FileUtils.mkdir_p(@output.dirname)
40
+
41
+ Dir.mktmpdir(".bake-node-", @output.dirname.to_s) do |temporary_root|
42
+ temporary_root = Pathname.new(temporary_root)
43
+ staging = temporary_root + "static"
44
+ backup = temporary_root + "backup"
45
+
46
+ build(staging)
47
+ replace(staging, backup)
48
+ end
49
+
50
+ Manifest.load(@output)
51
+ end
52
+
53
+ # Check whether the static package projection is current.
54
+ # @returns [Boolean] Whether the installed projection matches the desired output.
55
+ # @raises [CheckError] If the current manifest is missing or malformed.
56
+ def check
57
+ installed = Manifest.load(@output)
58
+
59
+ Dir.mktmpdir("bake-node-check-") do |temporary_root|
60
+ desired = build(Pathname.new(temporary_root) + "static")
61
+
62
+ return installed.data == desired.data && installed.valid_tree?(@output)
63
+ end
64
+ end
65
+
66
+ # Require the static package projection to be current.
67
+ # @returns [Boolean] `true` when the projection is current.
68
+ # @raises [CheckError] If the projection is missing or out of date.
69
+ def check!
70
+ unless check
71
+ raise CheckError, "Static Node.js packages are out of date. Run `bake node:packages:static`."
72
+ end
73
+
74
+ true
75
+ end
76
+
77
+ private
78
+
79
+ def build(destination)
80
+ FileUtils.mkdir_p(destination)
81
+
82
+ packages = {}
83
+ imports = {}
84
+
85
+ @configuration.packages.each_value do |package|
86
+ package_data, package_imports = install(package, destination)
87
+ packages[package.name] = package_data
88
+
89
+ package_imports.each do |specifier, path|
90
+ if imports.key?(specifier)
91
+ raise ConfigurationError, "Import specifier #{specifier.inspect} is configured more than once!"
92
+ end
93
+
94
+ imports[specifier] = path
95
+ end
96
+ end
97
+
98
+ manifest = Manifest.build(base: @configuration.base, imports: imports, packages: packages)
99
+ manifest.write(destination)
100
+ manifest
101
+ end
102
+
103
+ def install(package, destination)
104
+ package_path = @configuration.package_root + package.name
105
+
106
+ unless package_path.directory?
107
+ raise PackageError, "Package #{package.name} was not found at #{package_path}!"
108
+ end
109
+
110
+ package_root = package_path.realpath
111
+ source = package.source || default_source(package_root)
112
+ source_root = resolve_within(package_root, source, "source for #{package.name}")
113
+
114
+ unless source_root.directory?
115
+ raise PackageError, "Package source does not exist for #{package.name}: #{source_root}!"
116
+ end
117
+
118
+ paths = included_paths(package, source_root)
119
+ install_root = destination + package.name
120
+ files = {}
121
+
122
+ paths.each do |relative_path|
123
+ source_path = source_root + relative_path
124
+ install_path = install_root + relative_path
125
+
126
+ FileUtils.mkdir_p(install_path.dirname)
127
+ FileUtils.cp(source_path, install_path)
128
+ files[relative_path] = Digest::SHA256.file(install_path).hexdigest
129
+ end
130
+
131
+ package_json = read_package_json(package_root)
132
+ imports = resolve_imports(package, install_root)
133
+
134
+ [
135
+ {
136
+ "version" => package_json["version"],
137
+ "source" => source,
138
+ "files" => files.sort.to_h,
139
+ },
140
+ imports,
141
+ ]
142
+ end
143
+
144
+ def default_source(package_root)
145
+ (package_root + "dist").directory? ? "dist" : "."
146
+ end
147
+
148
+ def included_paths(package, source_root)
149
+ patterns = package.include_patterns || ["**/*"]
150
+
151
+ paths = patterns.flat_map do |pattern|
152
+ Dir.glob(pattern, File::FNM_DOTMATCH, base: source_root.to_s)
153
+ end.uniq.sort.select do |relative_path|
154
+ components = Pathname.new(relative_path).each_filename.to_a
155
+ next false if components.any?{|component| EXCLUDED_COMPONENTS.include?(component)}
156
+
157
+ path = source_root + relative_path
158
+ next false unless path.file?
159
+
160
+ resolve_within(source_root, relative_path, "included file for #{package.name}")
161
+ true
162
+ end
163
+
164
+ if paths.empty?
165
+ raise PackageError, "No files matched for #{package.name}!"
166
+ end
167
+
168
+ paths
169
+ end
170
+
171
+ def resolve_imports(package, install_root)
172
+ package.imports.sort.to_h do |specifier, value|
173
+ if value.match?(/\A(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i)
174
+ [specifier, value]
175
+ else
176
+ path = install_root + value
177
+
178
+ unless path.file?
179
+ raise PackageError, "Import #{specifier.inspect} refers to a file which was not installed: #{value.inspect}!"
180
+ end
181
+
182
+ base = @configuration.base
183
+ [specifier, "#{base}#{package.name}/#{value}"]
184
+ end
185
+ end
186
+ end
187
+
188
+ def read_package_json(package_root)
189
+ path = package_root + "package.json"
190
+ return {} unless path.file?
191
+
192
+ JSON.parse(path.read)
193
+ rescue JSON::ParserError => error
194
+ raise PackageError, "Could not parse #{path}: #{error.message}"
195
+ end
196
+
197
+ def resolve_within(root, relative_path, description)
198
+ path = (root + relative_path).realpath
199
+ prefix = root.to_s + File::SEPARATOR
200
+
201
+ unless path == root || path.to_s.start_with?(prefix)
202
+ raise PackageError, "The #{description} escapes #{root}: #{relative_path.inspect}!"
203
+ end
204
+
205
+ path
206
+ rescue Errno::ENOENT
207
+ raise PackageError, "The #{description} does not exist: #{relative_path.inspect}!"
208
+ end
209
+
210
+ def replace(staging, backup)
211
+ File.rename(@output, backup) if @output.exist?
212
+
213
+ begin
214
+ File.rename(staging, @output)
215
+ rescue Exception
216
+ File.rename(backup, @output) if backup.exist? && !@output.exist?
217
+ raise
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ # @namespace
7
+ module Bake
8
+ # @namespace
9
+ module Node
10
+ VERSION = "0.0.1"
11
+ end
12
+ end
data/lib/bake/node.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "node/version"
7
+ require_relative "node/errors"
8
+ require_relative "node/package"
9
+ require_relative "node/configuration"
10
+ require_relative "node/package_manager"
11
+ require_relative "node/manifest"
12
+ require_relative "node/static"
13
+ require_relative "node/controller"
data/license.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright, 2026, by Samuel Williams.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/readme.md ADDED
@@ -0,0 +1,70 @@
1
+ # Bake Node
2
+
3
+ Bake Node integrates Node.js packages into Ruby projects using [Bake](https://github.com/ioquatix/bake). It delegates installation and scripts to npm-compatible package managers, then materializes selected packages as deterministic static assets.
4
+
5
+ [![Development Status](https://github.com/socketry/bake-node/workflows/Test/badge.svg)](https://github.com/socketry/bake-node/actions?workflow=Test)
6
+
7
+ ## Features
8
+
9
+ - Supports npm, pnpm, Yarn and Bun without replacing their package-management behavior.
10
+ - Treats registry dependencies and local workspace packages consistently through `node_modules`.
11
+ - Copies complete distributions or carefully selected files into `public/_components`.
12
+ - Generates a deterministic manifest and browser import map.
13
+ - Provides Bake tasks for installation, scripts, static deployment and verification.
14
+
15
+ ## Usage
16
+
17
+ Please see the [project documentation](https://socketry.github.io/bake-node/) for more details.
18
+
19
+ - [Getting Started](https://socketry.github.io/bake-node/guides/getting-started/index) - This guide explains how to use `bake-node` to install an external JavaScript dependency and expose it as static assets from a Ruby project.
20
+
21
+ - [Internal Packages](https://socketry.github.io/bake-node/guides/internal-packages/index) - This guide explains how to organize JavaScript developed inside a Ruby project as independent workspace packages while using Bake Node for testing and static deployment.
22
+
23
+ - [Static Packages](https://socketry.github.io/bake-node/guides/static-packages/index) - This guide explains how to control which installed package files are deployed, generate browser import maps and verify the resulting static projection.
24
+
25
+ ## Releases
26
+
27
+ Please see the [project releases](https://socketry.github.io/bake-node/releases/index) for all releases.
28
+
29
+ ### v0.0.1
30
+
31
+ - [Added](https://socketry.github.io/bake-node/releases/index#added)
32
+
33
+ ## Contributing
34
+
35
+ We welcome contributions to this project.
36
+
37
+ 1. Fork the repository.
38
+ 2. Create your feature branch (`git checkout -b my-new-feature`).
39
+ 3. Commit your changes (`git commit -am 'Add some feature.'`).
40
+ 4. Push to the branch (`git push origin my-new-feature`).
41
+ 5. Create a new pull request.
42
+
43
+ ### Running Tests
44
+
45
+ To run the test suite:
46
+
47
+ ``` bash
48
+ $ bundle exec bake test
49
+ ```
50
+
51
+ ### Making Releases
52
+
53
+ To make a new release:
54
+
55
+ ``` bash
56
+ $ bundle exec bake gem:release:patch # or minor or major
57
+ ```
58
+
59
+ ### Developer Certificate of Origin
60
+
61
+ In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
62
+
63
+ ### Community Guidelines
64
+
65
+ This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
66
+
67
+ ## See Also
68
+
69
+ - [Bake](https://github.com/ioquatix/bake) — Ruby task execution.
70
+ - [Utopia](https://github.com/socketry/utopia) — The original static component installer.
data/releases.md ADDED
@@ -0,0 +1,8 @@
1
+ # Releases
2
+
3
+ ## v0.0.1
4
+
5
+ ### Added
6
+
7
+ - Add package-manager orchestration and static Node.js package materialization.
8
+ - Add deterministic manifests and import maps.