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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/utopia/server.rb +1 -1
- data/bake/utopia/site.rb +3 -3
- data/context/getting-started.md +93 -0
- data/context/index.yaml +32 -0
- data/context/integrating-with-javascript.md +75 -0
- data/context/middleware.md +157 -0
- data/context/server-setup.md +116 -0
- data/context/updating-utopia.md +69 -0
- data/context/what-is-xnode.md +41 -0
- data/lib/utopia/content/document.rb +39 -37
- data/lib/utopia/content/link.rb +1 -2
- data/lib/utopia/content/links.rb +2 -2
- data/lib/utopia/content/markup.rb +10 -10
- data/lib/utopia/content/middleware.rb +195 -0
- data/lib/utopia/content/namespace.rb +1 -1
- data/lib/utopia/content/node.rb +1 -1
- data/lib/utopia/content/response.rb +1 -1
- data/lib/utopia/content/tags.rb +1 -1
- data/lib/utopia/content.rb +4 -186
- data/lib/utopia/controller/actions.md +8 -8
- data/lib/utopia/controller/actions.rb +1 -1
- data/lib/utopia/controller/base.rb +4 -4
- data/lib/utopia/controller/middleware.rb +133 -0
- data/lib/utopia/controller/respond.rb +2 -46
- data/lib/utopia/controller/responder.rb +103 -0
- data/lib/utopia/controller/rewrite.md +2 -2
- data/lib/utopia/controller/rewrite.rb +1 -1
- data/lib/utopia/controller/variables.rb +11 -5
- data/lib/utopia/controller.rb +4 -126
- data/lib/utopia/exceptions/mailer.rb +4 -4
- data/lib/utopia/extensions/array_split.rb +2 -2
- data/lib/utopia/extensions/date_comparisons.rb +3 -3
- data/lib/utopia/import_map.rb +374 -0
- data/lib/utopia/localization/middleware.rb +173 -0
- data/lib/utopia/localization/wrapper.rb +52 -0
- data/lib/utopia/localization.rb +4 -202
- data/lib/utopia/path.rb +26 -11
- data/lib/utopia/redirection.rb +2 -2
- data/lib/utopia/session/lazy_hash.rb +1 -1
- data/lib/utopia/session/middleware.rb +218 -0
- data/lib/utopia/session/serialization.rb +1 -1
- data/lib/utopia/session.rb +4 -205
- data/lib/utopia/static/local_file.rb +19 -19
- data/lib/utopia/static/middleware.rb +120 -0
- data/lib/utopia/static/mime_types.rb +1 -1
- data/lib/utopia/static.rb +4 -108
- data/lib/utopia/version.rb +1 -1
- data/lib/utopia.rb +1 -0
- data/readme.md +7 -0
- data/releases.md +7 -0
- data/setup/site/config.ru +1 -1
- data.tar.gz.sig +0 -0
- metadata +31 -4
- metadata.gz.sig +0 -0
- data/lib/utopia/locale.rb +0 -29
- data/lib/utopia/responder.rb +0 -59
data/lib/utopia/controller.rb
CHANGED
|
@@ -3,134 +3,12 @@
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
4
|
# Copyright, 2009-2025, by Samuel Williams.
|
|
5
5
|
|
|
6
|
-
require_relative "
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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-
|
|
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
|