klenod-plugin-javascript 0.0.12-arm64-darwin → 0.0.13-arm64-darwin

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a54898b9aaec3cdc9e781c8831a9c10e0869c04328ee705b0d0499ed72af1b57
4
- data.tar.gz: 7078f9eb23f34832c388707f416da1871e6880d74e78f9320b7c63d50ebe0498
3
+ metadata.gz: d8003d1c103a1183915b6d50a9e52ab11be37d5fbb0e632fcc330bc2d28c8e0a
4
+ data.tar.gz: e1098c5f81720d13988f3a2c912c01837715da4eaa89bafa4351ffdb23b893f7
5
5
  SHA512:
6
- metadata.gz: 43aa6f4580d17412b7cd9d200aade31581f29b37f6ecec6073ea7e6633895942836f3d8b14898c6eed74cac7e6a12101bb976e131f4e7e206043cae4dc3fc333
7
- data.tar.gz: b5151c31524265e489596b6585828c5071ec0182d193e7288ec0817f9d937da8d66108d84e6794cc497dd0831bea0fe30936c965b9eec72486f64b77c1400964
6
+ metadata.gz: 37c31de0f9769147da4827b29b86af53aa4c12f09bec3e0701a253af5818ed2da93b5cf8e0907920ca82ae164df7aedcaa990bf4a2bfd521245e655482452add
7
+ data.tar.gz: f31672a34041c33c7aa6929642232065b1b8ed73c5e2fc4cc6b62103dd768c2d29c477293e6fecafda32889c9295bf5e07fe60838fb07f775b714c4400dbec28
data/README.md CHANGED
@@ -7,12 +7,10 @@ It:
7
7
 
8
8
  - collects static imports, re-exports, and string-literal dynamic imports
9
9
  - transforms JavaScript, TypeScript, JSX, and TSX
10
- - rewrites local imports to content-hashed asset paths
10
+ - rewrites graph imports to content-hashed asset paths
11
11
  - emits image and SVG wrappers backed by shared `ImageBase`, `ImageMetadata`, `ImageVariant`, and `SvgMetadata` classes, with frozen concrete metadata values, only for assets imported from JavaScript
12
12
  - preserves external URL imports
13
13
 
14
- Bare package imports and npm resolution are not currently supported.
15
-
16
14
  ## Options
17
15
 
18
16
  ```ruby
@@ -27,3 +25,79 @@ Klenod::Build::Plugins::JavaScriptPlugin.new(
27
25
  - `source_maps:` accepts `false`, `true`, or `:development` (the default).
28
26
  - `minify:` also minifies in development when `true`; build output is always
29
27
  minified.
28
+
29
+ ## npm Packages
30
+
31
+ `NodeModulesPlugin` adds npm packages to the Klenod graph. It is opt-in; register
32
+ it before `JavaScriptPlugin` so the JavaScript plugin can transform the files it
33
+ resolves:
34
+
35
+ ```ruby
36
+ plugins [
37
+ Klenod::Build::Plugins::NodeModulesPlugin.new,
38
+ Klenod::Build::Plugins::JavaScriptPlugin.new
39
+ ]
40
+ ```
41
+
42
+ Use an explicit `npm://` URL from application code. This keeps package lookup
43
+ separate from Klenod's usual rule that a bare specifier is relative to its
44
+ importer.
45
+
46
+ ```js
47
+ import * as THREE from "npm://three";
48
+ import { vec3 } from "npm://gl-matrix";
49
+ import { OrbitControls } from "npm://three/addons/controls/OrbitControls.js";
50
+ import { ReactiveElement } from "npm://@lit/reactive-element";
51
+ import { css } from "npm://@lit/reactive-element/css-tag.js";
52
+ ```
53
+
54
+ The part after `npm://` is the package name, followed by an optional exported
55
+ subpath. Scoped names retain the usual `@scope/package` form. The scheme works
56
+ with static imports, side-effect imports, re-exports, and string-literal dynamic
57
+ imports:
58
+
59
+ ```js
60
+ import "npm://@scope/widget";
61
+ export { html } from "npm://lit";
62
+ button.addEventListener("click", () => import("npm://three"));
63
+ ```
64
+
65
+ Exported subpaths must match the package's `exports` map. Include an extension
66
+ when the export includes one: use
67
+ `npm://three/addons/controls/OrbitControls.js` for Three.js, but
68
+ `npm://zod/mini` for Zod.
69
+
70
+ `NodeModulesPlugin` selects package exports using the `browser`, `import`,
71
+ `module`, and `default` conditions. It respects the order of conditions in
72
+ `package.json`. Packages without `exports` fall back to `module`, then `main`,
73
+ then `index.js`.
74
+
75
+ Once inside an npm package, relative imports continue within that package and
76
+ bare specifiers resolve its dependencies from the nearest `node_modules`
77
+ directory. Package self-references and `#` imports are supported through the
78
+ package's `exports` and `imports` maps. Extensionless package imports probe
79
+ `.js`, `.mjs`, `index.js`, and `index.mjs`; targets from an `exports` map are
80
+ exact and are not probed.
81
+
82
+ Resolved package files are ordinary graph modules. Klenod transforms them,
83
+ rewrites their imports, emits content-hashed assets, and minifies them in build
84
+ mode.
85
+
86
+ ### Configuration
87
+
88
+ - `root:` overrides the `node_modules` directory. By default the nearest one at
89
+ or above the source directory is used.
90
+ - `conditions:` overrides the matched export conditions. The default is
91
+ `browser`, `import`, `module`, and `default`, matched in the order the package
92
+ lists them.
93
+
94
+ ### Compatibility
95
+
96
+ Only ES modules are supported. CommonJS packages, Node builtins, the legacy
97
+ `browser` field, and JSON package imports are not supported. A `browser`
98
+ condition inside `exports` is supported.
99
+
100
+ Each package name can refer to only one physical directory in a graph because
101
+ module IDs use the form `npm://<package>/<path>`. If resolution finds two copies
102
+ of a package, Klenod reports the conflict; deduplicate that package in the
103
+ lockfile.
@@ -30,6 +30,9 @@ module Klenod
30
30
  JSX_RUNTIME_MODULE_ID = ModuleId.new("virtual:klenod/jsx-runtime.js", nil)
31
31
  LOCAL_SPECIFIER_PATTERN = %r{\A(?:\.{1,2}/|/|app:/)}
32
32
  EXTERNAL_SPECIFIER_PATTERN = %r{\A(?:[A-Za-z][A-Za-z0-9+.-]*:)?//}
33
+ # Also matches EXTERNAL_SPECIFIER_PATTERN, so npm imports have to be
34
+ # recognised before a specifier is treated as an external URL.
35
+ NPM_SPECIFIER_PATTERN = %r{\Anpm://}
33
36
  VALID_SOURCE_MAP_MODES = [false, true, :development].freeze
34
37
  IDENTIFIER_PATTERN = '[$_\p{Alpha}][$\u200c\u200d\p{Alnum}_]*'
35
38
  DEFAULT_EXPORT_CLASS_PATTERN = /\bexport\s+default\s+class\s+(#{IDENTIFIER_PATTERN})\b/
@@ -217,9 +220,13 @@ module Klenod
217
220
  end
218
221
 
219
222
  def build_dependencies(module_id, imports)
223
+ # Inside an npm package a bare specifier names another package, so
224
+ # let it reach the resolver instead of rejecting it here.
225
+ npm_importer = module_id.scheme == :npm
226
+
220
227
  imports.filter_map.with_index do |import, index|
221
- next if external_specifier?(import.specifier)
222
- raise DynamicImportError, unsupported_specifier_message(import) unless local_specifier?(import.specifier) || import.specifier == JSX_RUNTIME_SPECIFIER
228
+ next if external_specifier?(import.specifier) && !npm_specifier?(import.specifier)
229
+ raise DynamicImportError, unsupported_specifier_message(import) unless npm_importer || bundled_specifier?(import.specifier)
223
230
 
224
231
  Dependency
225
232
  .create(
@@ -415,13 +422,21 @@ module Klenod
415
422
  end
416
423
 
417
424
  def unsupported_specifier_message(import)
418
- "Unsupported JavaScript import #{import.specifier.inspect} at #{import.loc}. Only relative, app-root, and external URL imports are supported."
425
+ "Unsupported JavaScript import #{import.specifier.inspect} at #{import.loc}. Only relative, app-root, npm, and external URL imports are supported."
426
+ end
427
+
428
+ def bundled_specifier?(specifier)
429
+ local_specifier?(specifier) || npm_specifier?(specifier) || specifier == JSX_RUNTIME_SPECIFIER
419
430
  end
420
431
 
421
432
  def local_specifier?(specifier)
422
433
  specifier.match?(LOCAL_SPECIFIER_PATTERN)
423
434
  end
424
435
 
436
+ def npm_specifier?(specifier)
437
+ specifier.match?(NPM_SPECIFIER_PATTERN)
438
+ end
439
+
425
440
  def external_specifier?(specifier)
426
441
  specifier.match?(EXTERNAL_SPECIFIER_PATTERN)
427
442
  end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Build
5
+ module Plugins
6
+ module NodeModulesPlugin
7
+ # Resolves a package subpath to a package-relative file path using the
8
+ # metadata in a `package.json`.
9
+ #
10
+ # This module never touches the filesystem. It reports what a package
11
+ # claims about itself; checking that the returned path exists, and
12
+ # probing for extensions when it does not, belongs to the caller.
13
+ module PackageExports
14
+ # Conditions are matched in `package.json` key order, so this is a
15
+ # membership set rather than a priority list.
16
+ #
17
+ # "require" is absent on purpose. It names the CommonJS entry, which
18
+ # the JavaScript plugin cannot transform, and leaving it out turns a
19
+ # CommonJS-only package into a resolve-time error instead of a module
20
+ # that fails in the browser.
21
+ DEFAULT_CONDITIONS = ["browser", "import", "module", "default"].freeze
22
+
23
+ # Consulted in order when a package has no "exports" field. "module"
24
+ # precedes "main" because it is the conventional ES module entry,
25
+ # while "main" is usually CommonJS.
26
+ LEGACY_ENTRY_FIELDS = ["module", "main"].freeze
27
+ LEGACY_INDEX = "index.js"
28
+
29
+ # Only "require" names a CommonJS entry. "node" says nothing about the
30
+ # module system, so a node-only target is reported as an unmatched
31
+ # condition instead.
32
+ COMMONJS_CONDITIONS = ["require"].freeze
33
+ MAX_LISTED_SUBPATHS = 8
34
+
35
+ BLOCKED = :blocked
36
+
37
+ Result = Data.define(:path, :reason, :message) do
38
+ def self.resolved(path)
39
+ new(path, nil, nil)
40
+ end
41
+
42
+ def self.unresolved(reason, message)
43
+ new(nil, reason, message)
44
+ end
45
+
46
+ def resolved?
47
+ !path.nil?
48
+ end
49
+ end
50
+
51
+ module_function
52
+
53
+ # Resolve +subpath+ against +package_json+.
54
+ #
55
+ # Returns a Result whose +path+ is relative to the package root, or an
56
+ # unresolved Result carrying a +reason+ and a human-readable +message+.
57
+ def resolve(package_json, subpath: ".", conditions: DEFAULT_CONDITIONS)
58
+ return Result.unresolved(:invalid, "package.json must contain an object.") unless package_json.is_a?(Hash)
59
+
60
+ subpath = normalize_subpath(subpath)
61
+ return legacy_resolve(package_json, subpath) unless package_json.key?("exports")
62
+
63
+ resolve_exports(package_json["exports"], subpath, conditions)
64
+ end
65
+
66
+ # Resolve a "#"-prefixed private import against the package's "imports"
67
+ # field.
68
+ #
69
+ # Unlike an export target, an import target may name another package,
70
+ # so a resolved path keeps its "./" prefix when it refers to a file
71
+ # inside this package and is a bare specifier otherwise.
72
+ def resolve_private(package_json, specifier:, conditions: DEFAULT_CONDITIONS)
73
+ return Result.unresolved(:invalid, "package.json must contain an object.") unless package_json.is_a?(Hash)
74
+
75
+ unless specifier.is_a?(String) && specifier.start_with?("#") && specifier != "#" && !specifier.start_with?("#/")
76
+ return Result.unresolved(:invalid, "#{specifier.inspect} is not a valid private import.")
77
+ end
78
+
79
+ imports = package_json["imports"]
80
+
81
+ unless imports.is_a?(Hash)
82
+ return Result.unresolved(:no_match, "The package has no imports field to resolve #{specifier.inspect}.")
83
+ end
84
+
85
+ if imports.key?(specifier)
86
+ return resolve_matched_target(imports.fetch(specifier), specifier, conditions, nil, validate: false)
87
+ end
88
+
89
+ key, pattern_match = best_pattern_match(imports, specifier)
90
+
91
+ unless key
92
+ return Result.unresolved(
93
+ :no_match,
94
+ "The package does not define #{specifier.inspect}. Available private imports: #{imports.keys.sort.first(MAX_LISTED_SUBPATHS).join(", ")}."
95
+ )
96
+ end
97
+
98
+ resolve_matched_target(imports.fetch(key), specifier, conditions, pattern_match, validate: false)
99
+ end
100
+
101
+ def normalize_subpath(subpath)
102
+ value = subpath.to_s
103
+ return "." if value.empty? || value == "." || value == "./"
104
+ return value if value.start_with?("./")
105
+
106
+ "./#{value.delete_prefix("/")}"
107
+ end
108
+
109
+ def legacy_resolve(package_json, subpath)
110
+ return relative_path(subpath, "subpath") unless subpath == "."
111
+
112
+ field = LEGACY_ENTRY_FIELDS.find { package_json[it].is_a?(String) && !package_json[it].empty? }
113
+ return relative_path(LEGACY_INDEX, "default entry") unless field
114
+
115
+ relative_path(package_json.fetch(field), "#{field} field")
116
+ end
117
+
118
+ def resolve_exports(exports, subpath, conditions)
119
+ if exports.is_a?(Hash) && subpath_map?(exports)
120
+ return mixed_keys_error(exports) unless exports.keys.all? { it.start_with?(".") }
121
+
122
+ return resolve_subpath_map(exports, subpath, conditions)
123
+ end
124
+
125
+ # A string, an array, or a bare condition object is sugar for ".".
126
+ unless subpath == "."
127
+ return Result.unresolved(:no_match, "The package does not export #{subpath.inspect}. It only exports \".\".")
128
+ end
129
+
130
+ resolve_matched_target(exports, subpath, conditions, nil)
131
+ end
132
+
133
+ def subpath_map?(exports)
134
+ exports.keys.any? { it.start_with?(".") }
135
+ end
136
+
137
+ def resolve_subpath_map(exports, subpath, conditions)
138
+ return resolve_matched_target(exports.fetch(subpath), subpath, conditions, nil) if exports.key?(subpath)
139
+
140
+ key, pattern_match = best_pattern_match(exports, subpath)
141
+ return no_match_error(exports, subpath) unless key
142
+
143
+ resolve_matched_target(exports.fetch(key), subpath, conditions, pattern_match)
144
+ end
145
+
146
+ # Node picks the pattern key with the longest prefix before "*",
147
+ # breaking ties on the longest suffix after it.
148
+ def best_pattern_match(exports, subpath)
149
+ best_key = nil
150
+ best_prefix = nil
151
+ best_suffix = nil
152
+
153
+ exports.each_key do |key|
154
+ prefix, star, suffix = key.partition("*")
155
+ next if star.empty? || suffix.include?("*")
156
+ next unless subpath.start_with?(prefix) && subpath.end_with?(suffix)
157
+ next if subpath.length < prefix.length + suffix.length
158
+ next if best_key && !better_pattern?(prefix, suffix, best_prefix, best_suffix)
159
+
160
+ best_key = key
161
+ best_prefix = prefix
162
+ best_suffix = suffix
163
+ end
164
+
165
+ return [nil, nil] unless best_key
166
+
167
+ [best_key, subpath[best_prefix.length...(subpath.length - best_suffix.length)]]
168
+ end
169
+
170
+ def better_pattern?(prefix, suffix, best_prefix, best_suffix)
171
+ return true if prefix.length > best_prefix.length
172
+
173
+ prefix.length == best_prefix.length && suffix.length > best_suffix.length
174
+ end
175
+
176
+ def resolve_matched_target(target, subpath, conditions, pattern_match, validate: true)
177
+ resolved = resolve_target(target, conditions, pattern_match)
178
+
179
+ case resolved
180
+ when String
181
+ if validate
182
+ relative_path(resolved, "export target for #{subpath.inspect}")
183
+ else
184
+ private_target(resolved, subpath)
185
+ end
186
+ when BLOCKED
187
+ Result.unresolved(:blocked, "The package explicitly blocks #{subpath.inspect}.")
188
+ else
189
+ conditions_unmet_error(target, subpath, conditions)
190
+ end
191
+ end
192
+
193
+ # Returns the target path, BLOCKED when the package maps the subpath to
194
+ # null, or nil when no condition matched.
195
+ def resolve_target(target, conditions, pattern_match)
196
+ case target
197
+ when String
198
+ substitute(target, pattern_match)
199
+ when Hash
200
+ target.each do |condition, value|
201
+ next unless condition == "default" || conditions.include?(condition)
202
+
203
+ resolved = resolve_target(value, conditions, pattern_match)
204
+ return resolved if resolved
205
+ end
206
+ nil
207
+ when Array
208
+ # A blocked entry inside an array is a failed alternative rather
209
+ # than a block, so keep looking.
210
+ target.each do |value|
211
+ resolved = resolve_target(value, conditions, pattern_match)
212
+ return resolved if resolved.is_a?(String)
213
+ end
214
+ nil
215
+ when nil
216
+ BLOCKED
217
+ end
218
+ end
219
+
220
+ def substitute(target, pattern_match)
221
+ return target unless pattern_match && target.include?("*")
222
+
223
+ target.gsub("*", pattern_match)
224
+ end
225
+
226
+ # An "imports" target either points inside the package, keeping its
227
+ # "./" prefix so the caller can tell the two apart, or names another
228
+ # package.
229
+ def private_target(value, specifier)
230
+ description = "import target for #{specifier.inspect}"
231
+
232
+ if value.empty? || value.include?("*")
233
+ return Result.unresolved(:invalid, "The #{description} #{value.inspect} is not a usable target.")
234
+ end
235
+
236
+ unless value.start_with?("./")
237
+ if value.start_with?("../", "/", "#")
238
+ return Result.unresolved(:invalid, "The #{description} #{value.inspect} must stay within the package or name another package.")
239
+ end
240
+
241
+ return Result.resolved(value)
242
+ end
243
+
244
+ inner = value.delete_prefix("./")
245
+
246
+ if inner.empty? || inner.split("/").include?("..")
247
+ return Result.unresolved(:invalid, "The #{description} #{value.inspect} must stay within the package.")
248
+ end
249
+
250
+ Result.resolved(value)
251
+ end
252
+
253
+ def relative_path(value, description)
254
+ path = value.to_s.delete_prefix("./")
255
+
256
+ if path.include?("*")
257
+ return Result.unresolved(:invalid, "The #{description} #{value.inspect} has an unmatched wildcard.")
258
+ end
259
+
260
+ if path.empty? || path.start_with?("/") || path.split("/").include?("..")
261
+ return Result.unresolved(:invalid, "The #{description} #{value.inspect} must stay within the package.")
262
+ end
263
+
264
+ Result.resolved(path)
265
+ end
266
+
267
+ def conditions_unmet_error(target, subpath, conditions)
268
+ available = available_conditions(target)
269
+
270
+ if (available & COMMONJS_CONDITIONS).any?
271
+ return Result.unresolved(
272
+ :commonjs_only,
273
+ "#{subpath.inspect} resolves to a CommonJS entry only. Klenod can only import ES modules."
274
+ )
275
+ end
276
+
277
+ Result.unresolved(
278
+ :conditions_unmet,
279
+ "No condition matched for #{subpath.inspect}. " \
280
+ "Klenod uses #{conditions.to_a.join(", ")}; the package offers #{available.join(", ")}."
281
+ )
282
+ end
283
+
284
+ def available_conditions(target)
285
+ case target
286
+ when Hash then target.flat_map { |condition, value| [condition, *available_conditions(value)] }.uniq
287
+ when Array then target.flat_map { available_conditions(it) }.uniq
288
+ else []
289
+ end
290
+ end
291
+
292
+ def no_match_error(exports, subpath)
293
+ available = exports.keys.sort
294
+ listed = available.first(MAX_LISTED_SUBPATHS)
295
+ ellipsis = (available.length > listed.length) ? ", ..." : ""
296
+
297
+ Result.unresolved(
298
+ :no_match,
299
+ "The package does not export #{subpath.inspect}. Available subpaths: #{listed.join(", ")}#{ellipsis}."
300
+ )
301
+ end
302
+
303
+ def mixed_keys_error(exports)
304
+ conditions = exports.keys.reject { it.start_with?(".") }
305
+
306
+ Result.unresolved(
307
+ :invalid,
308
+ "package.json exports mixes subpaths with conditions: #{conditions.join(", ")}."
309
+ )
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require "klenod/build/dependency"
6
+ require "klenod/build/errors"
7
+ require "klenod/build/hashing"
8
+ require "klenod/build/load_result"
9
+ require "klenod/build/module_id"
10
+ require "klenod/build/plugin"
11
+
12
+ require_relative "node_modules_plugin/package_exports"
13
+
14
+ module Klenod
15
+ module Build
16
+ module Plugins
17
+ # Resolves `npm://` imports against an installed `node_modules` directory.
18
+ #
19
+ # Bare specifiers stay relative in Klenod, so npm packages are addressed
20
+ # through an explicit scheme: `import { vec3 } from "npm://gl-matrix"`.
21
+ # Once a module resolves to the npm scheme, every import inside it comes
22
+ # back to this plugin, which keeps package resolution from leaking into
23
+ # application resolution.
24
+ #
25
+ # Module IDs are `npm://<package>/<path within the package>`, so a package
26
+ # name identifies exactly one directory for the life of a graph. Finding
27
+ # the same name in two places is reported rather than resolved, because
28
+ # the ID has nowhere to record which copy a module came from.
29
+ module NodeModulesPlugin
30
+ def self.new(...)
31
+ Plugin.new(...)
32
+ end
33
+
34
+ class Plugin < Klenod::Build::Plugin
35
+ SCHEME = :npm
36
+ DIRECTORY_NAME = "node_modules"
37
+ NPM_SPECIFIER_PATTERN = %r{\Anpm://}
38
+ # A leading "/" addresses the package root, matching how Klenod treats
39
+ # "/foo" as relative to the current scheme root.
40
+ RELATIVE_SPECIFIER_PATTERN = %r{\A(?:\.{1,2}/|/)}
41
+ # Tried for imports that omit an extension. CommonJS builds are not
42
+ # probed for, since they cannot be transformed anyway.
43
+ CANDIDATE_EXTENSIONS = [".js", ".mjs"].freeze
44
+ INDEX_BASENAMES = ["index.js", "index.mjs"].freeze
45
+ NODE_SCHEME = "node:"
46
+ PRIVATE_PREFIX = "#"
47
+ # Only consulted when a bare specifier resolves to nothing, so a
48
+ # package that really did install a shim named "path" still wins.
49
+ NODE_BUILTINS = %w[
50
+ assert async_hooks buffer child_process cluster console constants crypto
51
+ dgram diagnostics_channel dns domain events fs http http2 https inspector
52
+ module net os path perf_hooks process punycode querystring readline repl
53
+ stream string_decoder sys timers tls trace_events tty url util v8 vm wasi
54
+ worker_threads zlib
55
+ ].freeze
56
+
57
+ def initialize(root: nil, conditions: PackageExports::DEFAULT_CONDITIONS)
58
+ @root = root && Pathname.new(root).expand_path
59
+ @conditions = conditions.freeze
60
+ @package_roots = {}
61
+ @package_metadata = {}
62
+ end
63
+
64
+ def resolve(dependency, context)
65
+ specifier = dependency.specifier.to_s
66
+
67
+ if specifier.match?(NPM_SPECIFIER_PATTERN)
68
+ return resolve_scheme(ModuleId.parse(specifier), dependency, context)
69
+ end
70
+
71
+ importer_id = dependency.importer_id
72
+ return nil unless importer_id&.scheme == SCHEME
73
+
74
+ # "node:fs" parses as a scheme, so it has to be caught before the
75
+ # check that hands other schemes back to the graph.
76
+ raise node_builtin_error(specifier.delete_prefix(NODE_SCHEME), importer_id) if specifier.start_with?(NODE_SCHEME)
77
+ return resolve_private(specifier, dependency, importer_id, context) if specifier.start_with?(PRIVATE_PREFIX)
78
+ return nil if specifier.match?(ModuleId::SCHEME_PATTERN)
79
+
80
+ if specifier.match?(RELATIVE_SPECIFIER_PATTERN)
81
+ resolve_relative(importer_id.merge(specifier), dependency, context)
82
+ else
83
+ resolve_bare(specifier, dependency, importer_id, context)
84
+ end
85
+ rescue ResolveError => error
86
+ raise error.with_resolution_context(dependency: dependency, importer_id: dependency.importer_id)
87
+ end
88
+
89
+ def load(module_id, context)
90
+ return nil unless module_id.scheme == SCHEME
91
+
92
+ path = absolute_path(module_id, context)
93
+ LoadResult.new(path.read(encoding: "UTF-8"), Hashing.file_hexdigest(path), nil)
94
+ end
95
+
96
+ private
97
+
98
+ # An "npm://name/subpath" import from application code.
99
+ def resolve_scheme(module_id, dependency, context)
100
+ package_name, subpath = split_package(module_id)
101
+ package_root = root_package_root(package_name, context)
102
+
103
+ resolve_entry(package_name, package_root, subpath, dependency, module_id)
104
+ end
105
+
106
+ # A bare specifier inside a package names another package, or the
107
+ # package itself.
108
+ def resolve_bare(specifier, dependency, importer_id, context)
109
+ package_name, subpath = split_specifier(specifier)
110
+ importer_package, importer_subpath = split_package(importer_id)
111
+
112
+ package_root =
113
+ if package_name == importer_package
114
+ self_reference_root(package_name, subpath, importer_id, context)
115
+ else
116
+ transitive_package_root(package_name, importer_package, importer_subpath, importer_id, context)
117
+ end
118
+
119
+ resolve_entry(package_name, package_root, subpath, dependency, "#{specifier.inspect} in #{importer_id}")
120
+ end
121
+
122
+ # A "#"-prefixed specifier resolves against the importing package's own
123
+ # "imports" field, and may land on a file in that package or on another
124
+ # package entirely.
125
+ def resolve_private(specifier, dependency, importer_id, context)
126
+ importer_package, = split_package(importer_id)
127
+ package_root = package_root_for(importer_package, context)
128
+ metadata = package_metadata(importer_package, package_root)
129
+ result = PackageExports.resolve_private(metadata, specifier: specifier, conditions: @conditions)
130
+ requested = "#{specifier.inspect} in #{importer_id}"
131
+
132
+ unless result.resolved?
133
+ raise ResolveError, "Could not resolve #{requested} (#{result.reason}): #{result.message}"
134
+ end
135
+
136
+ target = result.path
137
+ return resolve_bare(target, dependency, importer_id, context) unless target.start_with?("./")
138
+
139
+ resolved(dependency, importer_package, package_root, target.delete_prefix("./"), requested, probe: true)
140
+ end
141
+
142
+ def resolve_relative(module_id, dependency, context)
143
+ package_name, subpath = split_package(module_id)
144
+ package_root = package_root_for(package_name, context)
145
+
146
+ resolved(dependency, package_name, package_root, subpath.delete_prefix("./"), module_id, probe: true)
147
+ end
148
+
149
+ def resolve_entry(package_name, package_root, subpath, dependency, requested)
150
+ metadata = package_metadata(package_name, package_root)
151
+ result = PackageExports.resolve(metadata, subpath: subpath, conditions: @conditions)
152
+
153
+ unless result.resolved?
154
+ raise ResolveError, "Could not resolve #{requested} (#{result.reason}): #{result.message}"
155
+ end
156
+
157
+ # An "exports" target names an exact file. Only the legacy fields and
158
+ # bare subpaths of packages without "exports" are probed.
159
+ resolved(dependency, package_name, package_root, result.path, requested, probe: !metadata.key?("exports"))
160
+ end
161
+
162
+ def resolved(dependency, package_name, package_root, relative_path, requested, probe: false)
163
+ path = package_root.join(relative_path).expand_path
164
+ assert_inside_package!(package_root, path, requested)
165
+
166
+ found = probe ? probe_path(path) : (path if path.file?)
167
+
168
+ unless found
169
+ raise ResolveError, "Could not resolve #{requested}. #{package_name} has no file at #{relative_path}."
170
+ end
171
+
172
+ assert_inside_package!(package_root, found, requested)
173
+
174
+ ResolvedDependency.new(
175
+ dependency,
176
+ module_id_for(package_name, found.relative_path_from(package_root).to_s),
177
+ {scheme: SCHEME, path: found.to_s}
178
+ )
179
+ end
180
+
181
+ def probe_path(path)
182
+ return path if path.file?
183
+
184
+ CANDIDATE_EXTENSIONS.each do |extension|
185
+ candidate = Pathname.new("#{path}#{extension}")
186
+ return candidate if candidate.file?
187
+ end
188
+
189
+ return nil unless path.directory?
190
+
191
+ INDEX_BASENAMES.each do |basename|
192
+ candidate = path.join(basename)
193
+ return candidate if candidate.file?
194
+ end
195
+
196
+ nil
197
+ end
198
+
199
+ def absolute_path(module_id, context)
200
+ package_name, subpath = split_package(module_id)
201
+ package_root = package_root_for(package_name, context)
202
+ path = package_root.join(subpath.delete_prefix("./")).expand_path
203
+ assert_inside_package!(package_root, path, module_id)
204
+
205
+ path
206
+ end
207
+
208
+ # "npm://gl-matrix/esm/vec3.js" is package "gl-matrix" and subpath
209
+ # "./esm/vec3.js". Scoped packages put the scope in the authority, so
210
+ # "npm://@scope/pkg/sub.js" spends its first path segment on the name.
211
+ def split_package(module_id)
212
+ host = module_id.host.to_s
213
+ raise ResolveError, "npm imports need a package name: #{module_id}" if host.empty?
214
+
215
+ segments = module_id.relative_path.split("/")
216
+
217
+ if host.start_with?("@")
218
+ scoped_name = segments.shift
219
+ raise ResolveError, "Scoped npm imports need a package name: #{module_id}" if scoped_name.to_s.empty?
220
+
221
+ package_name = "#{host}/#{scoped_name}"
222
+ else
223
+ package_name = host
224
+ end
225
+
226
+ [package_name, subpath_for(segments)]
227
+ end
228
+
229
+ # "@scope/pkg/sub.js" is package "@scope/pkg" and subpath "./sub.js".
230
+ def split_specifier(specifier)
231
+ segments = specifier.split("/")
232
+ package_name = segments.shift
233
+
234
+ if package_name.start_with?("@")
235
+ scoped_name = segments.shift
236
+ raise ResolveError, "Scoped npm imports need a package name: #{specifier}" if scoped_name.to_s.empty?
237
+
238
+ package_name = "#{package_name}/#{scoped_name}"
239
+ end
240
+
241
+ [package_name, subpath_for(segments)]
242
+ end
243
+
244
+ def subpath_for(segments)
245
+ segments.empty? ? "." : "./#{segments.join("/")}"
246
+ end
247
+
248
+ def module_id_for(package_name, relative_path)
249
+ ModuleId.new("npm://#{package_name}/#{relative_path}")
250
+ end
251
+
252
+ # Node only allows a package to import itself by name when it declares
253
+ # "exports", because that field is what defines its own entry points.
254
+ def self_reference_root(package_name, subpath, importer_id, context)
255
+ package_root = package_root_for(package_name, context)
256
+
257
+ unless package_metadata(package_name, package_root).key?("exports")
258
+ raise ResolveError,
259
+ "#{importer_id} imports its own package as #{package_name.inspect}, " \
260
+ "but #{package_name} has no exports field to resolve #{subpath.inspect} against."
261
+ end
262
+
263
+ package_root
264
+ end
265
+
266
+ def transitive_package_root(package_name, importer_package, importer_subpath, importer_id, context)
267
+ importer_root = package_root_for(importer_package, context)
268
+ start = importer_root.join(importer_subpath.delete_prefix("./")).dirname
269
+
270
+ each_node_modules_directory(start, context) do |directory|
271
+ candidate = directory.join(package_name)
272
+ return register_package_root(package_name, candidate) if candidate.directory?
273
+ end
274
+
275
+ raise node_builtin_error(package_name, importer_id) if NODE_BUILTINS.include?(package_name)
276
+
277
+ raise ResolveError,
278
+ "Could not find package #{package_name.inspect} imported by #{importer_id}. " \
279
+ "Is it a dependency of #{importer_package}?"
280
+ end
281
+
282
+ def node_builtin_error(name, importer_id)
283
+ ResolveError.new(
284
+ "#{importer_id} imports the Node builtin #{name.inspect}, which has no browser equivalent. " \
285
+ "Klenod bundles for the browser, so packages that need Node builtins cannot be used."
286
+ )
287
+ end
288
+
289
+ # Node's lookup: every parent directory contributes its "node_modules",
290
+ # nearest first. The configured root is tried last so an explicit root:
291
+ # still applies when it sits outside the importer's tree.
292
+ def each_node_modules_directory(start, context)
293
+ start.ascend do |directory|
294
+ next if directory.basename.to_s == DIRECTORY_NAME
295
+
296
+ candidate = directory.join(DIRECTORY_NAME)
297
+ yield candidate if candidate.directory?
298
+ end
299
+
300
+ root = node_modules_root(context)
301
+ yield root if root.directory?
302
+ end
303
+
304
+ def package_root_for(package_name, context)
305
+ @package_roots[package_name] || root_package_root(package_name, context)
306
+ end
307
+
308
+ def root_package_root(package_name, context)
309
+ return @package_roots.fetch(package_name) if @package_roots.key?(package_name)
310
+
311
+ root = node_modules_root(context)
312
+ path = root.join(package_name)
313
+
314
+ unless path.directory?
315
+ raise ResolveError, "Could not find package #{package_name.inspect} in #{root}. Is it installed?"
316
+ end
317
+
318
+ register_package_root(package_name, path)
319
+ end
320
+
321
+ # Module IDs carry only a package name, so a name has to mean one
322
+ # directory. Resolve symlinks first, so pnpm's store and workspace
323
+ # links are recognised as the same package rather than two copies.
324
+ def register_package_root(package_name, path)
325
+ path = path.realpath
326
+ existing = @package_roots[package_name]
327
+
328
+ if existing && existing != path
329
+ raise ResolveError,
330
+ "Found two copies of #{package_name.inspect}:\n #{existing}\n #{path}\n" \
331
+ "Klenod can only bundle one copy of a package. Deduplicate it in your lockfile."
332
+ end
333
+
334
+ @package_roots[package_name] = path
335
+ end
336
+
337
+ def package_metadata(package_name, package_root)
338
+ @package_metadata.fetch(package_name) do
339
+ path = package_root.join("package.json")
340
+
341
+ raise ResolveError, "Package #{package_name.inspect} has no package.json at #{path}." unless path.file?
342
+
343
+ @package_metadata[package_name] = JSON.parse(path.read(encoding: "UTF-8"))
344
+ rescue JSON::ParserError => error
345
+ raise ResolveError, "Could not parse #{path}: #{error.message}"
346
+ end
347
+ end
348
+
349
+ def node_modules_root(context)
350
+ return @root if @root
351
+
352
+ @node_modules_root = discover_root(context.source_dir) unless defined?(@node_modules_root)
353
+
354
+ @node_modules_root || raise(ResolveError, "Could not find a #{DIRECTORY_NAME} directory above #{context.source_dir}.")
355
+ end
356
+
357
+ def discover_root(source_dir)
358
+ Pathname.new(source_dir).expand_path.ascend do |directory|
359
+ candidate = directory.join(DIRECTORY_NAME)
360
+ return candidate if candidate.directory?
361
+ end
362
+
363
+ nil
364
+ end
365
+
366
+ def assert_inside_package!(package_root, path, requested)
367
+ prefix = "#{package_root}#{File::SEPARATOR}"
368
+ return if path.to_s.start_with?(prefix)
369
+
370
+ raise ResolveError, "npm import escapes its package: #{requested}"
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
@@ -4,7 +4,7 @@ module Klenod
4
4
  module Build
5
5
  module Plugins
6
6
  module JavaScriptPlugin
7
- VERSION = "0.0.12"
7
+ VERSION = "0.0.13"
8
8
  end
9
9
  end
10
10
  end
@@ -2,3 +2,4 @@
2
2
 
3
3
  require "klenod/build"
4
4
  require_relative "../build/plugins/javascript_plugin"
5
+ require_relative "../build/plugins/node_modules_plugin"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: klenod-plugin-javascript
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.12
4
+ version: 0.0.13
5
5
  platform: arm64-darwin
6
6
  authors:
7
7
  - Andrés Alin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-08 00:00:00.000000000 Z
11
+ date: 2026-09-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: klenod-build
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - '='
18
18
  - !ruby/object:Gem::Version
19
- version: 0.0.12
19
+ version: 0.0.13
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - '='
25
25
  - !ruby/object:Gem::Version
26
- version: 0.0.12
26
+ version: 0.0.13
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rake-compiler
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -47,6 +47,8 @@ extra_rdoc_files: []
47
47
  files:
48
48
  - README.md
49
49
  - lib/klenod/build/plugins/javascript_plugin.rb
50
+ - lib/klenod/build/plugins/node_modules_plugin.rb
51
+ - lib/klenod/build/plugins/node_modules_plugin/package_exports.rb
50
52
  - lib/klenod/plugin/javascript.rb
51
53
  - lib/klenod/plugin/javascript/native.bundle
52
54
  - lib/klenod/plugin/javascript/parser.rb