utopia 3.0.4 → 3.0.6

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: 3e9f5a6eb41a075cebcde2daf0e8811e1af414776f7807a14e07e9ed228c7657
4
- data.tar.gz: 1d566c0e5b68123699f6f33185857ce00e6eb3b1659eae5c0f0b896443a36527
3
+ metadata.gz: 29332f03b21ff72e75b4f372deb3b317469ff3a5f2991ad363214b144d68ee4d
4
+ data.tar.gz: 3e3405b9bc409eeaf96e575bbb4f61fceb16298630b63f6069c2f443c2264025
5
5
  SHA512:
6
- metadata.gz: dc90dd50e914c27b28463992bbe33b0ef52eb077bd666519b876575202cee39babfed39720e2c22b66f3c456460bdcd16446876bb2827714b9665ed72c655ce0
7
- data.tar.gz: 18b31ba92269e3340cc385e2a8b953bbe7b75b0db1f7323dd4e00d55faa03ebf71a097a940de69c84183a5115d2e982ba0a8aa2f480ca495a8d2119a93d2aa4f
6
+ metadata.gz: 4f376ca1d057b6a91002e1abcab1ef98c0754772203c8d44392eca14e41df72b59e79bdb37a81e29624a6e0dd696e71ae63623a3ede9afec76b400825a8063fd
7
+ data.tar.gz: 4c20f0613d97496fd0fe1c4584384a1022677378b84504173817cdb6f6eb1a5405c440319ee482222e7c1fe7c8eea9401e1f7df89871607be8eeb023c9bf6487
checksums.yaml.gz.sig CHANGED
Binary file
@@ -8,20 +8,45 @@ Import maps provide a modern way to manage JavaScript module dependencies. Utopi
8
8
 
9
9
  ### Installing JavaScript Libraries
10
10
 
11
- First, install the library using npm:
11
+ 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:
12
+
13
+ ```json
14
+ {
15
+ "private": true,
16
+ "dependencies": {
17
+ "@socketry/syntax": "^0.6.1"
18
+ },
19
+ "bake-node": {
20
+ "packages": {
21
+ "@socketry/syntax": {
22
+ "include": [
23
+ "Syntax.js"
24
+ ],
25
+ "imports": {
26
+ "@socketry/syntax": "Syntax.js"
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ Install the dependencies using the configured package manager, then generate the browser-facing package projection:
12
35
 
13
36
  ```bash
14
- $ npm install jquery
37
+ $ bundle exec bake node:install
38
+ $ bundle exec bake node:packages:static
15
39
  ```
16
40
 
17
- Copy the distribution files to `public/_components`:
41
+ 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.
42
+
43
+ Use an immutable installation and verify the checked-in projection in CI:
18
44
 
19
45
  ```bash
20
- $ bundle exec bake utopia:components:update
46
+ $ bundle exec bake node:install frozen=true
47
+ $ bundle exec bake node:packages:check
21
48
  ```
22
49
 
23
- This will copy the library's distribution files (typically from `node_modules/*/dist/`) to your `public/_components/` directory, making them available for local serving.
24
-
25
50
  ### Creating the Import Map
26
51
 
27
52
  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`:
30
55
  require "utopia/import_map"
31
56
 
32
57
  module MyWebsite
33
- IMPORT_MAP = Utopia::ImportMap.build(base: "/_components/") do |map|
34
- map.import("jquery", "./jquery/jquery.js")
35
- end
58
+ IMPORT_MAP = Utopia::ImportMap.load_manifest("public/_components")
36
59
  end
37
60
  ```
38
61
 
62
+ This loads the generated package mappings directly, so the browser import map remains synchronized with `package.json`.
63
+
39
64
  Then load this in `lib/my_website.rb`:
40
65
 
41
66
  ```ruby
@@ -64,15 +89,16 @@ Once the import map is set up, you can import and use the library in your script
64
89
  ```xrb
65
90
  <script type="module">
66
91
  // <![CDATA[
67
- import $ from 'jquery';
92
+ import Syntax from '@socketry/syntax';
68
93
 
69
- $(document).ready(function() {
70
- console.log("jQuery is ready!");
71
- });
94
+ await Syntax.highlight();
72
95
  // ]]>
73
96
  </script>
74
97
  ```
75
98
 
99
+ Inspect the generated import map with `bundle exec bake node:importmap:show` when debugging package resolution.
100
+
101
+ See the [Bake Node documentation](https://socketry.github.io/bake-node/) for workspace packages, package selection, and alternative package managers.
76
102
 
77
103
  ### Advanced Import Map Features
78
104
 
@@ -167,7 +167,7 @@ This template would typically be designed with supporting `_page.xnode` and `_he
167
167
 
168
168
  ## Session
169
169
 
170
- The {ruby Utopia::Session} middleware provides session storage using encrypted client-side cookies. The session management uses symmetric private key encryption to store data on the client and avoid tampering.
170
+ The {ruby Utopia::Session} middleware provides session storage using authenticated, encrypted client-side cookies. The session management uses symmetric private key encryption to store data on the client and prevent tampering.
171
171
 
172
172
  ```ruby
173
173
  use Utopia::Session,
@@ -177,7 +177,7 @@ use Utopia::Session,
177
177
  secure: true
178
178
  ```
179
179
 
180
- All session data is stored on the client, but it's encrypted with a salt and the secret key. It is impossible for the client to decrypt the data without the secret stored on the server.
180
+ All session data is stored on the client, but it is encrypted and authenticated with a key derived from the secret. The client cannot read or modify the data without the secret stored on the server.
181
181
 
182
182
  When the middleware is installed, the session is available on the request:
183
183
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../../../../utopia/content/middleware"
7
+
8
+ require "traces/provider"
9
+
10
+ Traces::Provider(Utopia::Content::Middleware) do
11
+ def respond(link, request, localization: request.localization)
12
+ attributes = {
13
+ "link.key" => link.key,
14
+ "link.href" => link.href,
15
+ "link.locale" => localization&.locale,
16
+ }
17
+
18
+ Traces.trace("utopia.content.respond", attributes: attributes){super}
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../../../../utopia/static/middleware"
7
+
8
+ require "traces/provider"
9
+
10
+ Traces::Provider(Utopia::Static::Middleware) do
11
+ def respond(request, path, extension, content_type, localization: request.localization)
12
+ attributes = {
13
+ path: path,
14
+ locale: localization&.locale,
15
+ }
16
+
17
+ Traces.trace("utopia.static.respond", attributes: attributes){super}
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "utopia/content/middleware"
7
+ require_relative "utopia/static/middleware"
@@ -82,11 +82,7 @@ module Utopia
82
82
  def text(content)
83
83
  return unless content
84
84
 
85
- if content.respond_to?(:build_markup)
86
- content.build_markup(self)
87
- else
88
- XRB::Markup.append(@output, content)
89
- end
85
+ content.build_markup(self)
90
86
  end
91
87
 
92
88
  # Write a complete tag to the output.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2015-2025, by Samuel Williams.
4
+ # Copyright, 2015-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "link"
7
7
 
@@ -204,8 +204,10 @@ module Utopia
204
204
  def each(locale)
205
205
  return to_enum(:each, locale) unless block_given?
206
206
 
207
- ordered.each do |links|
208
- yield links.find{|link| link.locale == locale}
207
+ @named.each_key do |name|
208
+ if link = lookup(name, locale)
209
+ yield link
210
+ end
209
211
  end
210
212
  end
211
213
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2009-2025, by Samuel Williams.
4
+ # Copyright, 2009-2026, by Samuel Williams.
5
5
 
6
6
  require "xrb/parsers"
7
7
  require "xrb/entities"
@@ -108,9 +108,9 @@ module Utopia
108
108
  # @returns [String] The resulting string.
109
109
  def to_s
110
110
  if @closing_tag
111
- "#{start_location}: #{@opening_tag} was not closed!"
112
- else
113
111
  "#{start_location}: #{@opening_tag} was closed by #{@closing_tag}!"
112
+ else
113
+ "#{start_location}: #{@opening_tag} was not closed!"
114
114
  end
115
115
  end
116
116
  end
@@ -16,7 +16,6 @@ require_relative "tags"
16
16
 
17
17
  require "xrb/template"
18
18
  require "concurrent/map"
19
- require "traces/provider"
20
19
 
21
20
  module Utopia
22
21
  module Content
@@ -66,11 +65,7 @@ module Utopia
66
65
  end
67
66
 
68
67
  attr :root
69
-
70
- # TODO we should remove this method and expose `@links` directly.
71
- def links(path, **options)
72
- @links.index(path, **options)
73
- end
68
+ attr :links
74
69
 
75
70
  # Load and cache a content template.
76
71
  # @parameter path [Utopia::Path | String] The path.
@@ -159,12 +154,7 @@ module Utopia
159
154
  private
160
155
 
161
156
  def lookup_content(name, parent_path)
162
- if String === name && name.index("/")
163
- name = Path.create(name)
164
- end
165
-
166
157
  if Path === name
167
- name = parent_path + name
168
158
  name_path = name.components.dup
169
159
  name_path[-1] += XNODE_EXTENSION
170
160
  else
@@ -195,6 +185,21 @@ module Utopia
195
185
  end
196
186
 
197
187
  def content_tag(name, node, parent_path: node.parent_path)
188
+ # Preserve nested names while searching the physical content hierarchy:
189
+ if String === name
190
+ if name.index("/")
191
+ name = Path.create(name)
192
+ end
193
+ end
194
+
195
+ if Path === name
196
+ cache_key = parent_path + name
197
+
198
+ return @node_cache.fetch_or_store(cache_key) do
199
+ lookup_content(name, parent_path)
200
+ end
201
+ end
202
+
198
203
  full_path = parent_path + name
199
204
 
200
205
  name = full_path.pop
@@ -216,16 +221,5 @@ module Utopia
216
221
  end
217
222
  end
218
223
 
219
- Traces::Provider(Middleware) do
220
- def respond(link, request, localization: request.localization)
221
- attributes = {
222
- "link.key" => link.key,
223
- "link.href" => link.href,
224
- "link.locale" => localization&.locale,
225
- }
226
-
227
- Traces.trace("utopia.content.middleware.respond", attributes: attributes){super}
228
- end
229
- end
230
224
  end
231
225
  end
@@ -51,14 +51,12 @@ module Utopia
51
51
  def local_path(path = ".", base = nil)
52
52
  path = Path[path]
53
53
 
54
- root = Pathname.new(@controller.root)
55
-
56
- if path.absolute?
57
- return root.join(*path.components)
58
- else
54
+ if path.relative?
59
55
  base ||= uri_path.dirname
60
- return root.join(*(base + path).components)
56
+ path = base + path
61
57
  end
58
+
59
+ return Pathname.new(path.to_url_path.local_path(@controller.root))
62
60
  end
63
61
 
64
62
  # Resolve a path relative to this node's containing URI path.
@@ -84,7 +82,7 @@ module Utopia
84
82
  def links(path = ".", **options, &block)
85
83
  path = uri_path.dirname + Path[path]
86
84
 
87
- links = @controller.links(path, **options)
85
+ links = @controller.links.index(path, **options)
88
86
 
89
87
  if block_given?
90
88
  links.each(&block)
@@ -96,7 +94,7 @@ module Utopia
96
94
  # Return localized and indexed variants related to this node.
97
95
  # @returns [Array(Link)] The related links.
98
96
  def related_links
99
- @controller.links(@uri_path.dirname, name: @uri_path.basename, indices: true)
97
+ @controller.links.index(@uri_path.dirname, name: @uri_path.basename, indices: true)
100
98
  end
101
99
 
102
100
  # Return the directory whose links are siblings of this node.
@@ -113,7 +111,7 @@ module Utopia
113
111
  # @parameter options [Hash] The options.
114
112
  # @returns [Array(Link)] The sibling links.
115
113
  def sibling_links(**options)
116
- return @controller.links(siblings_path, **options)
114
+ return @controller.links.index(siblings_path, **options)
117
115
  end
118
116
 
119
117
  # Lookup the given tag which is being rendered within the given node. Invoked by {Document}.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2015-2025, by Samuel Williams.
4
+ # Copyright, 2015-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "../http"
7
7
  require_relative "../path/matcher"
@@ -48,6 +48,8 @@ module Utopia
48
48
  # Freeze this object and its internal state.
49
49
  # @returns [self] This object.
50
50
  def freeze
51
+ return self if frozen?
52
+
51
53
  @matcher.freeze
52
54
  @block.freeze
53
55
 
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Utopia
7
+ module Exceptions
8
+ # Exceptions raised by application code which can be safely handled and reported.
9
+ APPLICATION_ERRORS = [StandardError, ScriptError].freeze
10
+ end
11
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2014-2025, by Samuel Williams.
4
+ # Copyright, 2014-2026, by Samuel Williams.
5
5
  # Copyright, 2025, by Olle Jonsson.
6
6
 
7
7
  require "console"
@@ -9,6 +9,7 @@ require "console"
9
9
  require_relative "../middleware"
10
10
  require_relative "../request"
11
11
  require_relative "../response"
12
+ require_relative "application_errors"
12
13
 
13
14
  module Utopia
14
15
  module Exceptions
@@ -37,7 +38,7 @@ module Utopia
37
38
  def call(request)
38
39
  begin
39
40
  return @delegate.call(request)
40
- rescue Exception => exception
41
+ rescue *APPLICATION_ERRORS => exception
41
42
  Console.warn(self, "An error occurred while processing the request.", error: exception)
42
43
 
43
44
  begin
@@ -52,7 +53,7 @@ module Utopia
52
53
  error_response.status = 500
53
54
 
54
55
  return error_response
55
- rescue Exception => exception
56
+ rescue *APPLICATION_ERRORS => exception
56
57
  # If redirection fails, we also finish with a fatal error:
57
58
  Console.error(self, "An error occurred while invoking the error handler.", error: exception)
58
59
  return Response[500, {"content-type" => "text/plain"}, ["An error occurred while processing the request."]]
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2016-2025, by Samuel Williams.
4
+ # Copyright, 2016-2026, by Samuel Williams.
5
5
 
6
6
  require "net/smtp"
7
7
  require "mail"
8
+ require "console"
9
+ require "stringio"
10
+ require "yaml"
8
11
 
9
12
  require_relative "../middleware"
10
13
  require_relative "../request"
11
- require_relative "handler"
14
+ require_relative "application_errors"
12
15
 
13
16
  module Utopia
14
17
  module Exceptions
@@ -23,20 +26,33 @@ module Utopia
23
26
 
24
27
  DEFAULT_FROM = (ENV["USER"] || "utopia").freeze
25
28
  DEFAULT_SUBJECT = "%{exception} [PID %{pid} : %{cwd}]".freeze
29
+ ATTACHMENT_SIZE_LIMIT = 64*1024
30
+ SENSITIVE_FIELD = /authorization|cookie|credential|password|private[_-]?key|referer|referrer|secret|session|token|variables|api[_-]?key/i
31
+ REDACTED = "[REDACTED]".freeze
26
32
 
27
33
  # @param to [String] The address to email error reports to.
28
34
  # @param from [String] The from address for error reports.
29
35
  # @param subject [String] The subject template which can access attributes defined by `#attributes_for`.
30
36
  # @param delivery_method [Object] The delivery method as required by the mail gem.
31
- # @param dump_environment [Boolean] Attach request attributes as `attributes.yaml` to the error report.
32
- def initialize(app, to: "postmaster", from: DEFAULT_FROM, subject: DEFAULT_SUBJECT, delivery_method: LOCAL_SMTP, dump_environment: false)
37
+ # @param dump_body [Boolean] Attach a bounded rewindable request body to the error report.
38
+ # @param dump_environment [Boolean] Include application state and attach it as `state.yaml`.
39
+ # @param attachment_size_limit [Integer] The maximum size of each attachment.
40
+ # @param redact [Regexp | Nil] A pattern matching structured field names whose values should be redacted.
41
+ def initialize(app, to: "postmaster", from: DEFAULT_FROM, subject: DEFAULT_SUBJECT, delivery_method: LOCAL_SMTP, dump_body: false, dump_environment: false, attachment_size_limit: ATTACHMENT_SIZE_LIMIT, redact: SENSITIVE_FIELD)
33
42
  super(app)
34
43
 
35
44
  @to = to
36
45
  @from = from
37
46
  @subject = subject
38
47
  @delivery_method = delivery_method
48
+ @dump_body = dump_body
39
49
  @dump_environment = dump_environment
50
+ @attachment_size_limit = Integer(attachment_size_limit)
51
+ @redact = redact
52
+
53
+ if @attachment_size_limit < 0
54
+ raise ArgumentError, "attachment_size_limit must not be negative!"
55
+ end
40
56
  end
41
57
 
42
58
  # Freeze this object and its internal state.
@@ -48,7 +64,10 @@ module Utopia
48
64
  @from.freeze
49
65
  @subject.freeze
50
66
  @delivery_method.freeze
67
+ @dump_body.freeze
51
68
  @dump_environment.freeze
69
+ @attachment_size_limit.freeze
70
+ @redact.freeze
52
71
 
53
72
  super
54
73
  end
@@ -59,7 +78,7 @@ module Utopia
59
78
  def call(request)
60
79
  begin
61
80
  return @delegate.call(request)
62
- rescue => exception
81
+ rescue *APPLICATION_ERRORS => exception
63
82
  request.exception = exception
64
83
  send_notification exception, request
65
84
 
@@ -99,30 +118,32 @@ module Utopia
99
118
  def generate_body(exception, request)
100
119
  io = StringIO.new
101
120
 
102
- io.puts "#{request.method} #{request.url}"
103
-
104
- # TODO embed the request body if it's textual?
105
- # TODO dump and embed `utopia.variables`?
121
+ # Do not include the raw query string, as it may contain sensitive values:
122
+ io.puts "#{request.method} #{request.url.path.encoded}"
106
123
 
107
124
  io.puts
108
125
 
109
126
  REQUEST_ATTRIBUTES.each do |key|
110
- value = request.send(key)
127
+ value = redact(key, request.send(key))
111
128
  io.puts "request.#{key}: #{value.inspect}"
112
129
  end
113
130
 
114
131
  request.query_parameters.each do |key, value|
132
+ value = redact(key, value)
115
133
  io.puts "request.query_parameters.#{key}: #{value.inspect}"
116
134
  end
117
135
 
118
136
  io.puts
119
137
 
120
138
  request.headers.each do |key, value|
139
+ value = redact(key, value)
121
140
  io.puts "header[#{key.inspect}]: #{value.inspect}"
122
141
  end
123
142
 
124
- self.current_state(request).each do |key, value|
125
- io.puts "state.#{key}: #{value.inspect}"
143
+ if @dump_environment
144
+ filtered_state(request).each do |key, value|
145
+ io.puts "state.#{key}: #{value.inspect}"
146
+ end
126
147
  end
127
148
 
128
149
  io.puts
@@ -150,12 +171,14 @@ module Utopia
150
171
  mail.text_part = Mail::Part.new
151
172
  mail.text_part.body = generate_body(exception, request)
152
173
 
153
- if body = extract_body(request) and body.size > 0
154
- mail.attachments["body.bin"] = body
174
+ if @dump_body
175
+ if body = extract_body(request, @attachment_size_limit)
176
+ mail.attachments["body.bin"] = body
177
+ end
155
178
  end
156
179
 
157
180
  if @dump_environment
158
- mail.attachments["state.yaml"] = YAML.dump(self.current_state(request))
181
+ attach(mail, "state.yaml", YAML.dump(filtered_state(request)))
159
182
  end
160
183
 
161
184
  return mail
@@ -168,8 +191,7 @@ module Utopia
168
191
 
169
192
  mail.deliver
170
193
  rescue => mail_exception
171
- $stderr.puts mail_exception.to_s
172
- $stderr.puts mail_exception.backtrace
194
+ Console.warn(self, "Failed to deliver exception notification.", error: mail_exception)
173
195
  end
174
196
 
175
197
  def current_state(request)
@@ -181,11 +203,51 @@ module Utopia
181
203
  }
182
204
  end
183
205
 
184
- def extract_body(request)
206
+ def filtered_state(request)
207
+ redact(nil, current_state(request))
208
+ end
209
+
210
+ def redact(name, value)
211
+ if @redact && name
212
+ if @redact.match?(name.to_s)
213
+ return REDACTED
214
+ end
215
+ end
216
+
217
+ case value
218
+ when Hash
219
+ return value.to_h do |key, item|
220
+ [key, redact(key, item)]
221
+ end
222
+ when Array
223
+ return value.map{|item| redact(nil, item)}
224
+ else
225
+ return value
226
+ end
227
+ end
228
+
229
+ def attach(mail, name, content)
230
+ if content.bytesize <= @attachment_size_limit
231
+ mail.attachments[name] = content
232
+ end
233
+ end
234
+
235
+ def extract_body(request, size_limit)
185
236
  body = request.body
186
237
 
187
238
  if body&.rewindable? && body.rewind
188
- return body.join
239
+ buffer = String.new.b
240
+
241
+ body.each do |chunk|
242
+ # Do not retain a partial body when the complete attachment would exceed the limit:
243
+ if chunk.bytesize > size_limit - buffer.bytesize
244
+ return nil
245
+ end
246
+
247
+ buffer << chunk
248
+ end
249
+
250
+ return buffer unless buffer.empty?
189
251
  end
190
252
  end
191
253
  end
@@ -6,6 +6,7 @@
6
6
  require "json"
7
7
  require "xrb"
8
8
  require "protocol/url"
9
+ require "bake/node/manifest"
9
10
 
10
11
  module Utopia
11
12
  # Represents an import map for JavaScript modules with support for URI and relative path resolution.
@@ -55,6 +56,19 @@ module Utopia
55
56
  #
56
57
  # puts page_map.to_html
57
58
  class ImportMap
59
+ # Load the import mappings from a Bake Node static package manifest.
60
+ # @parameter root [String | Pathname] The static package output directory.
61
+ # @returns [ImportMap] A frozen import map using the manifest's public base URL.
62
+ def self.load_manifest(root)
63
+ manifest = Bake::Node::Manifest.load(root)
64
+ base = Protocol::URL[manifest.data.fetch("base")]
65
+ imports = manifest.import_map.fetch("imports").transform_values do |value|
66
+ Protocol::URL[value].relative_to(base).to_s
67
+ end
68
+
69
+ return self.new(imports, base: base).freeze
70
+ end
71
+
58
72
  # Builder class for constructing import maps with scoped base URIs.
59
73
  #
60
74
  # The builder supports nested `with(base:)` blocks where each base is resolved