utopia 2.30.2 → 2.31.0

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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/bake/utopia/server.rb +1 -1
  4. data/bake/utopia/site.rb +3 -3
  5. data/context/getting-started.md +93 -0
  6. data/context/index.yaml +32 -0
  7. data/context/integrating-with-javascript.md +75 -0
  8. data/context/middleware.md +157 -0
  9. data/context/server-setup.md +116 -0
  10. data/context/updating-utopia.md +69 -0
  11. data/context/what-is-xnode.md +41 -0
  12. data/lib/utopia/content/document.rb +39 -37
  13. data/lib/utopia/content/link.rb +1 -2
  14. data/lib/utopia/content/links.rb +2 -2
  15. data/lib/utopia/content/markup.rb +10 -10
  16. data/lib/utopia/content/middleware.rb +195 -0
  17. data/lib/utopia/content/namespace.rb +1 -1
  18. data/lib/utopia/content/node.rb +1 -1
  19. data/lib/utopia/content/response.rb +1 -1
  20. data/lib/utopia/content/tags.rb +1 -1
  21. data/lib/utopia/content.rb +4 -186
  22. data/lib/utopia/controller/actions.md +8 -8
  23. data/lib/utopia/controller/actions.rb +1 -1
  24. data/lib/utopia/controller/base.rb +4 -4
  25. data/lib/utopia/controller/middleware.rb +133 -0
  26. data/lib/utopia/controller/respond.rb +2 -46
  27. data/lib/utopia/controller/responder.rb +103 -0
  28. data/lib/utopia/controller/rewrite.md +2 -2
  29. data/lib/utopia/controller/rewrite.rb +1 -1
  30. data/lib/utopia/controller/variables.rb +11 -5
  31. data/lib/utopia/controller.rb +4 -126
  32. data/lib/utopia/exceptions/mailer.rb +4 -4
  33. data/lib/utopia/extensions/array_split.rb +2 -2
  34. data/lib/utopia/extensions/date_comparisons.rb +3 -3
  35. data/lib/utopia/import_map.rb +374 -0
  36. data/lib/utopia/localization/middleware.rb +173 -0
  37. data/lib/utopia/localization/wrapper.rb +52 -0
  38. data/lib/utopia/localization.rb +4 -202
  39. data/lib/utopia/path.rb +26 -11
  40. data/lib/utopia/redirection.rb +2 -2
  41. data/lib/utopia/session/lazy_hash.rb +1 -1
  42. data/lib/utopia/session/middleware.rb +218 -0
  43. data/lib/utopia/session/serialization.rb +1 -1
  44. data/lib/utopia/session.rb +4 -205
  45. data/lib/utopia/static/local_file.rb +19 -19
  46. data/lib/utopia/static/middleware.rb +120 -0
  47. data/lib/utopia/static/mime_types.rb +1 -1
  48. data/lib/utopia/static.rb +4 -108
  49. data/lib/utopia/version.rb +1 -1
  50. data/lib/utopia.rb +1 -0
  51. data/readme.md +7 -0
  52. data/releases.md +7 -0
  53. data/setup/site/config.ru +1 -1
  54. data.tar.gz.sig +0 -0
  55. metadata +31 -4
  56. metadata.gz.sig +0 -0
  57. data/lib/utopia/locale.rb +0 -29
  58. data/lib/utopia/responder.rb +0 -59
@@ -3,134 +3,12 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2009-2025, by Samuel Williams.
5
5
 
6
- require_relative "path"
7
-
8
- require_relative "middleware"
9
- require_relative "controller/variables"
10
- require_relative "controller/base"
11
-
12
- require_relative "controller/rewrite"
13
- require_relative "controller/respond"
14
- require_relative "controller/actions"
15
-
16
- require "concurrent/map"
6
+ require_relative "controller/middleware"
17
7
 
18
8
  module Utopia
19
- # A middleware which loads controller classes and invokes functionality based on the requested path.
20
- class Controller
21
- # The controller filename.
22
- CONTROLLER_RB = "controller.rb".freeze
23
-
24
- def self.[] request
25
- request.env[VARIABLES_KEY]
26
- end
27
-
28
- # @param root [String] The content root where controllers will be loaded from.
29
- # @param base [Class] The base class for controllers.
30
- def initialize(app, root: Utopia::default_root, base: Controller::Base)
31
- @app = app
32
- @root = root
33
-
34
- @controller_cache = Concurrent::Map.new
35
-
36
- @base = base
37
- end
38
-
39
- attr :app
40
-
41
- def freeze
42
- return self if frozen?
43
-
44
- @root.freeze
45
- @base.freeze
46
-
47
- super
48
- end
49
-
50
- # Fetch the controller for the given relative path. May be cached.
51
- def lookup_controller(path)
52
- @controller_cache.fetch_or_store(path.to_s) do
53
- load_controller_file(path)
54
- end
55
- end
56
-
57
- # Loads the controller file for the given relative url_path.
58
- def load_controller_file(uri_path)
59
- base_path = File.join(@root, uri_path.components)
60
-
61
- controller_path = File.join(base_path, CONTROLLER_RB)
62
- # puts "load_controller_file(#{path.inspect}) => #{controller_path}"
63
-
64
- if File.exist?(controller_path)
65
- klass = Class.new(@base)
66
-
67
- # base_path is expected to be a string representing a filesystem path:
68
- klass.const_set(:BASE_PATH, base_path.freeze)
69
-
70
- # uri_path is expected to be an instance of Path:
71
- klass.const_set(:URI_PATH, uri_path.dup.freeze)
72
-
73
- klass.const_set(:CONTROLLER, self)
74
-
75
- klass.class_eval(File.read(controller_path), controller_path)
76
-
77
- # We lock down the controller class to prevent unsafe modifications:
78
- klass.freeze
79
-
80
- # Create an instance of the controller:
81
- return klass.new
82
- else
83
- return nil
84
- end
85
- end
86
-
87
- # Invoke the controller layer for a given request. The request path may be rewritten.
88
- def invoke_controllers(request)
89
- request_path = Path.from_string(request.path_info)
90
-
91
- # The request path must be absolute. We could handle this internally but it is probably better for this to be an error:
92
- raise ArgumentError.new("Invalid request path #{request_path}") unless request_path.absolute?
93
-
94
- # The controller path contains the current complete path being evaluated:
95
- controller_path = Path.new
96
-
97
- # Controller instance variables which eventually get processed by the view:
98
- variables = request.env[VARIABLES_KEY]
99
-
100
- while request_path.components.any?
101
- # We copy one path component from the relative path to the controller path at a time. The controller, when invoked, can modify the relative path (by assigning to relative_path.components). This allows for controller-relative rewrites, but only the remaining path postfix can be modified.
102
- controller_path.components << request_path.components.shift
103
-
104
- if controller = lookup_controller(controller_path)
105
- # Don't modify the original controller:
106
- controller = controller.clone
107
-
108
- # Append the controller to the set of controller variables, updates the controller with all current instance variables.
109
- variables << controller
110
-
111
- if result = controller.process!(request, request_path)
112
- return result
113
- end
114
- end
115
- end
116
-
117
- # Controllers can directly modify relative_path, which is copied into controller_path. The controllers may have rewriten the path so we update the path info:
118
- request.env[Rack::PATH_INFO] = controller_path.to_s
119
-
120
- # No controller gave a useful result:
121
- return nil
122
- end
123
-
124
- def call(env)
125
- env[VARIABLES_KEY] ||= Variables.new
126
-
127
- request = Rack::Request.new(env)
128
-
129
- if result = invoke_controllers(request)
130
- return result
131
- end
132
-
133
- return @app.call(env)
9
+ module Controller
10
+ def self.new(...)
11
+ Middleware.new(...)
134
12
  end
135
13
  end
136
14
  end
@@ -154,7 +154,7 @@ module Utopia
154
154
 
155
155
  mail.text_part = Mail::Part.new
156
156
  mail.text_part.body = generate_body(exception, env)
157
-
157
+
158
158
  if body = extract_body(env) and body.size > 0
159
159
  mail.attachments["body.bin"] = body
160
160
  end
@@ -162,10 +162,10 @@ module Utopia
162
162
  if @dump_environment
163
163
  mail.attachments["environment.yaml"] = YAML.dump(env)
164
164
  end
165
-
165
+
166
166
  return mail
167
167
  end
168
-
168
+
169
169
  def send_notification(exception, env)
170
170
  mail = generate_mail(exception, env)
171
171
 
@@ -176,7 +176,7 @@ module Utopia
176
176
  $stderr.puts mail_exception.to_s
177
177
  $stderr.puts mail_exception.backtrace
178
178
  end
179
-
179
+
180
180
  def extract_body(env)
181
181
  if io = env["rack.input"]
182
182
  io.rewind if io.respond_to?(:rewind)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2009-2024, by Samuel Williams.
4
+ # Copyright, 2009-2025, by Samuel Williams.
5
5
 
6
6
  module Utopia
7
7
  module Extensions
@@ -14,7 +14,7 @@ module Utopia
14
14
  end
15
15
  end
16
16
  end
17
-
17
+
18
18
  ::Array.prepend(ArraySplit)
19
19
  end
20
20
  end
@@ -17,9 +17,9 @@ module Utopia
17
17
  end
18
18
  end
19
19
  end
20
-
20
+
21
21
  ::Time.prepend(TimeDateComparison)
22
-
22
+
23
23
  # Provides comparison operator extensions.
24
24
  module DateTimeComparison
25
25
  def <=>(other)
@@ -30,7 +30,7 @@ module Utopia
30
30
  end
31
31
  end
32
32
  end
33
-
33
+
34
34
  ::Date.prepend(DateTimeComparison)
35
35
  end
36
36
  end
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require "json"
7
+ require "xrb"
8
+ require "protocol/url"
9
+
10
+ module Utopia
11
+ # Represents an import map for JavaScript modules with support for URI and relative path resolution.
12
+ # Import maps allow you to control how JavaScript imports are resolved, supporting both absolute
13
+ # URLs and relative paths with proper context-aware resolution.
14
+ #
15
+ # The builder pattern supports nested base URIs that are properly resolved relative to parent bases.
16
+ # All URL resolution follows RFC 3986 via the `protocol-url` gem.
17
+ #
18
+ # @example Basic usage with absolute URLs.
19
+ # import_map = Utopia::ImportMap.build do |map|
20
+ # map.import("react", "https://esm.sh/react@18")
21
+ # map.import("@myapp/utils", "./js/utils.js", integrity: "sha384-...")
22
+ # end
23
+ #
24
+ # puts import_map.to_html
25
+ #
26
+ # @example Using nested base URIs for different CDNs.
27
+ # import_map = Utopia::ImportMap.build do |map|
28
+ # # Imports without base
29
+ # map.import("app", "/app.js")
30
+ #
31
+ # # CDN imports - base is set to jsdelivr
32
+ # map.with(base: "https://cdn.jsdelivr.net/npm/") do |m|
33
+ # m.import "lit", "lit@2.7.5/index.js"
34
+ # m.import "lit/decorators.js", "lit@2.7.5/decorators.js"
35
+ # end
36
+ #
37
+ # # Nested base combines with parent: "https://cdn.jsdelivr.net/npm/mermaid@10/"
38
+ # map.with(base: "https://cdn.jsdelivr.net/npm/") do |m|
39
+ # m.with(base: "mermaid@10/") do |nested|
40
+ # nested.import "mermaid", "dist/mermaid.esm.min.mjs"
41
+ # end
42
+ # end
43
+ # end
44
+ #
45
+ # @example Creating page-specific import maps with relative paths.
46
+ # # Global import map with base: "/_components/"
47
+ # global_map = Utopia::ImportMap.build(base: "/_components/") do |map|
48
+ # map.import("button", "./button.js")
49
+ # end
50
+ #
51
+ # # For a page at /foo/bar/, create a context-specific import map
52
+ # page_map = global_map.relative_to("/foo/bar/")
53
+ # # Base becomes: "../../_components/"
54
+ # # button import resolves to: "../../_components/button.js"
55
+ #
56
+ # puts page_map.to_html
57
+ class ImportMap
58
+ # Builder class for constructing import maps with scoped base URIs.
59
+ #
60
+ # The builder supports nested `with(base:)` blocks where each base is resolved
61
+ # relative to its parent base, following RFC 3986 URL resolution rules.
62
+ #
63
+ # @example Nested base resolution.
64
+ # ImportMap.build do |map|
65
+ # # No base - imports as-is
66
+ # map.import("app", "/app.js")
67
+ #
68
+ # # Base: "https://cdn.example.com/"
69
+ # map.with(base: "https://cdn.example.com/") do |cdn|
70
+ # cdn.import("lib", "lib.js") # => "https://cdn.example.com/lib.js"
71
+ #
72
+ # # Nested base: "https://cdn.example.com/" + "v2/" = "https://cdn.example.com/v2/"
73
+ # cdn.with(base: "v2/") do |v2|
74
+ # v2.import("new-lib", "lib.js") # => "https://cdn.example.com/v2/lib.js"
75
+ # end
76
+ # end
77
+ # end
78
+ class Builder
79
+ def self.build(import_map, **options, &block)
80
+ builder = self.new(import_map, **options)
81
+
82
+ if block.arity == 1
83
+ yield(builder)
84
+ else
85
+ builder.instance_eval(&block)
86
+ end
87
+
88
+ return builder
89
+ end
90
+
91
+ def initialize(import_map, base: nil)
92
+ @import_map = import_map
93
+ @base = Protocol::URL[base]
94
+ end
95
+
96
+ # Add an import mapping with the current base URI.
97
+ #
98
+ # If a base is set, the value is resolved relative to that base following RFC 3986.
99
+ # Absolute URLs (scheme://...) are preserved as-is when used as values.
100
+ #
101
+ # @parameter specifier [String] The module specifier (e.g., "react", "@myapp/utils").
102
+ # @parameter value [String] The URL or path to resolve to.
103
+ # @parameter integrity [String, nil] Optional subresource integrity hash.
104
+ # @returns [Builder] Self for method chaining.
105
+ #
106
+ # @example With base URL.
107
+ # builder = Builder.new(map, base: "https://cdn.com/")
108
+ # builder.import("lib", "lib.js") # Resolves to: "https://cdn.com/lib.js"
109
+ # builder.import("ext", "https://other.com/ext.js") # Keeps: "https://other.com/ext.js"
110
+ def import(specifier, value, integrity: nil)
111
+ resolved_value = if @base
112
+ value_url = Protocol::URL[value]
113
+
114
+ # Combine base with value
115
+ (@base + value_url).to_s
116
+ else
117
+ value
118
+ end
119
+
120
+ @import_map.import(specifier, resolved_value, integrity: integrity)
121
+
122
+ self
123
+ end
124
+
125
+ # Create a nested scope with a different base URI.
126
+ #
127
+ # The new base is resolved relative to the current base. This allows for
128
+ # hierarchical organization of imports from different sources.
129
+ #
130
+ # @parameter base [String] The new base URI, resolved relative to current base.
131
+ # @yields [Builder] A new builder with the resolved base.
132
+ # @returns [Builder] The builder instance.
133
+ #
134
+ # @example Nested CDN paths.
135
+ # builder.with(base: "https://cdn.com/") do |cdn|
136
+ # cdn.with(base: "libs/v2/") do |v2|
137
+ # # Base is now: "https://cdn.com/libs/v2/"
138
+ # v2.import("util", "util.js") # => "https://cdn.com/libs/v2/util.js"
139
+ # end
140
+ # end
141
+ def with(base:, &block)
142
+ # Resolve the new base relative to the current base
143
+ resolved_base = if @base
144
+ @base + Protocol::URL[base]
145
+ else
146
+ base
147
+ end
148
+
149
+ self.class.build(@import_map, base: resolved_base, &block)
150
+ end
151
+
152
+ # Add a scope mapping.
153
+ #
154
+ # Scopes allow different import resolutions for different parts of your application.
155
+ #
156
+ # @parameter scope_prefix [String] The scope prefix (e.g., "/pages/").
157
+ # @parameter imports [Hash] Import mappings specific to this scope.
158
+ # @returns [Builder] Self for method chaining.
159
+ #
160
+ # @example Scope-specific imports.
161
+ # builder.scope("/admin/", {"utils" => "/admin/utils.js"})
162
+ def scope(scope_prefix, imports)
163
+ @import_map.scope(scope_prefix, imports)
164
+ self
165
+ end
166
+ end
167
+
168
+ # Create an import map using a builder pattern.
169
+ #
170
+ # The builder supports both block parameter and instance_eval styles.
171
+ # The returned import map is frozen to prevent accidental mutation.
172
+ #
173
+ # @parameter base [String, nil] The base URI for resolving relative paths.
174
+ # @yields {|builder| ...} If a block is given.
175
+ # @parameter builder [Builder] The import map builder, if the block takes an argument.
176
+ # @returns [ImportMap] A frozen import map instance.
177
+ #
178
+ # @example Block parameter style.
179
+ # import_map = ImportMap.build do |map|
180
+ # map.import("react", "https://esm.sh/react")
181
+ # end
182
+ #
183
+ # @example Instance eval style.
184
+ # import_map = ImportMap.build do
185
+ # import "react", "https://esm.sh/react"
186
+ # end
187
+ def self.build(base: nil, &block)
188
+ instance = self.new(base: base)
189
+
190
+ builder = Builder.build(instance, &block)
191
+
192
+ return instance.freeze
193
+ end
194
+
195
+ # Initialize a new import map.
196
+ #
197
+ # Typically you should use {build} instead of calling this directly.
198
+ #
199
+ # @parameter imports [Hash] The imports mapping.
200
+ # @parameter integrity [Hash] Integrity hashes for imports.
201
+ # @parameter scopes [Hash] Scoped import mappings.
202
+ # @parameter base [String, Protocol::URL, nil] The base URI for resolving relative paths.
203
+ def initialize(imports = {}, integrity = {}, scopes = {}, base: nil)
204
+ @imports = imports
205
+ @integrity = integrity
206
+ @scopes = scopes
207
+ @base = Protocol::URL[base]
208
+ end
209
+
210
+ # @attribute [Hash(String, String)] The imports mapping.
211
+ attr :imports
212
+
213
+ # @attribute [Hash(String, String)] Subresource integrity hashes for imports.
214
+ attr :integrity
215
+
216
+ # @attribute [Hash(String, Hash)] Scoped import mappings.
217
+ attr :scopes
218
+
219
+ # @attribute [Protocol::URL::Absolute | Protocol::URL::Relative | nil] The parsed base URL for efficient resolution.
220
+ attr :base
221
+
222
+ # Add an import mapping.
223
+ #
224
+ # @parameter specifier [String] The import specifier (e.g., "react", "@myapp/utils").
225
+ # @parameter value [String] The URL or path to resolve to.
226
+ # @parameter integrity [String, nil] Optional subresource integrity hash for the resource.
227
+ # @returns [ImportMap] Self for method chaining.
228
+ def import(specifier, value, integrity: nil)
229
+ @imports[specifier] = value
230
+ @integrity[specifier] = integrity if integrity
231
+
232
+ self
233
+ end
234
+
235
+ # Add a scope mapping.
236
+ #
237
+ # Scopes allow different import resolutions based on the referrer URL.
238
+ # See https://github.com/WICG/import-maps#scoping-examples for details.
239
+ #
240
+ # @parameter scope_prefix [String] The scope prefix (e.g., "/pages/").
241
+ # @parameter imports [Hash] Import mappings specific to this scope.
242
+ # @returns [ImportMap] Self for method chaining.
243
+ def scope(scope_prefix, imports)
244
+ @scopes[scope_prefix] = imports
245
+
246
+ self
247
+ end
248
+
249
+ # Create a new import map with paths relative to the given page path.
250
+ # This is useful for creating page-specific import maps from a global one.
251
+ #
252
+ # @parameter path [String] The absolute page path to make imports relative to.
253
+ # @returns [ImportMap] A new import map with a relative base.
254
+ #
255
+ # @example Creating page-specific import maps.
256
+ # # Global import map with base: "/_components/"
257
+ # import_map = ImportMap.build(base: "/_components/") { ... }
258
+ #
259
+ # # For a page at /foo/bar/, calculate relative path to components
260
+ # page_map = import_map.relative_to("/foo/bar/")
261
+ # # Base becomes: "../../_components/"
262
+ def relative_to(path)
263
+ if @base
264
+ # Calculate the relative path from the page to the base
265
+ relative_base = Protocol::URL::Path.relative(@base.path, path)
266
+ resolved_base = Protocol::URL[relative_base]
267
+ else
268
+ resolved_base = nil
269
+ end
270
+
271
+ instance = self.class.new(@imports.dup, @integrity.dup, @scopes.dup, base: resolved_base)
272
+
273
+ return instance.freeze
274
+ end
275
+
276
+ # Resolve a single import value considering base context.
277
+ #
278
+ # @parameter value [String] The import URL or path value.
279
+ # @parameter base [Protocol::URL, nil] The base URL context for resolving relative paths.
280
+ # @returns [Protocol::URL, String] The resolved URL object or original string.
281
+ private def resolve_value(value, base)
282
+ if base
283
+ base + Protocol::URL[value]
284
+ else
285
+ value
286
+ end
287
+ end
288
+
289
+ # Resolve a hash of imports with the given base.
290
+ #
291
+ # @parameter imports [Hash] The imports hash to resolve.
292
+ # @parameter base [Protocol::URL, nil] The base URL context.
293
+ # @returns [Hash] The resolved imports with string values.
294
+ private def resolve_imports(imports, base)
295
+ result = {}
296
+
297
+ imports.each do |specifier, value|
298
+ result[specifier] = resolve_value(value, base).to_s
299
+ end
300
+
301
+ result
302
+ end
303
+
304
+ # Build the import map as a Hash with resolved paths.
305
+ #
306
+ # All relative paths are resolved against the base URL if present.
307
+ # Absolute URLs and protocol-relative URLs are preserved as-is.
308
+ # This method is compatible with the JSON gem's `as_json` convention.
309
+ #
310
+ # @returns [Hash] The resolved import map data structure ready for JSON serialization.
311
+ def as_json(...)
312
+ result = {}
313
+
314
+ # Add imports
315
+ if @imports.any?
316
+ result["imports"] = resolve_imports(@imports, @base)
317
+ end
318
+
319
+ # Add scopes
320
+ if @scopes.any?
321
+ result["scopes"] = {}
322
+ @scopes.each do |scope_prefix, scope_imports|
323
+ # Resolve the scope prefix itself with base
324
+ scope_url = Protocol::URL[scope_prefix]
325
+ resolved_prefix = if @base && !scope_url.is_a?(Protocol::URL::Absolute)
326
+ (@base + scope_url).to_s
327
+ else
328
+ scope_prefix
329
+ end
330
+
331
+ result["scopes"][resolved_prefix] = resolve_imports(scope_imports, @base)
332
+ end
333
+ end
334
+
335
+ # Add integrity
336
+ if @integrity.any?
337
+ result["integrity"] = @integrity.dup
338
+ end
339
+
340
+ return result
341
+ end
342
+
343
+ # Convert the import map to JSON.
344
+ #
345
+ # @returns [String] The JSON representation of the import map.
346
+ def to_json(...)
347
+ as_json.to_json(...)
348
+ end
349
+
350
+ # Generate the import map as an XRB fragment suitable for embedding in HTML.
351
+ #
352
+ # Creates a `<script type="importmap">` tag containing the JSON representation.
353
+ #
354
+ # @returns [XRB::Builder::Fragment] The generated HTML fragment.
355
+ def to_html
356
+ json_data = to_json
357
+
358
+ XRB::Builder.fragment do |builder|
359
+ builder.inline("script", type: "importmap") do
360
+ builder.raw(json_data)
361
+ end
362
+ end
363
+ end
364
+
365
+ # Convenience method for rendering the import map as an HTML string.
366
+ #
367
+ # Equivalent to `to_html.to_s`.
368
+ #
369
+ # @returns [String] The generated HTML containing the import map script tag.
370
+ def to_s
371
+ to_html.to_s
372
+ end
373
+ end
374
+ end