utopia 2.32.0 → 3.0.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 (85) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/bake/utopia/environment.rb +6 -6
  4. data/bake/utopia/server.rb +4 -1
  5. data/bake/utopia/site.rb +14 -14
  6. data/bake/utopia/static.rb +26 -13
  7. data/bake/utopia.rb +1 -1
  8. data/context/getting-started.md +7 -5
  9. data/context/index.yaml +1 -2
  10. data/context/middleware.md +42 -13
  11. data/lib/utopia/application.rb +83 -0
  12. data/lib/utopia/content/builder.rb +25 -0
  13. data/lib/utopia/content/document.rb +64 -6
  14. data/lib/utopia/content/link.rb +50 -5
  15. data/lib/utopia/content/links.rb +44 -7
  16. data/lib/utopia/content/markup.rb +74 -0
  17. data/lib/utopia/content/middleware.rb +48 -20
  18. data/lib/utopia/content/namespace.rb +10 -1
  19. data/lib/utopia/content/node.rb +66 -4
  20. data/lib/utopia/content/response.rb +12 -3
  21. data/lib/utopia/content.rb +3 -0
  22. data/lib/utopia/controller/actions.md +4 -4
  23. data/lib/utopia/controller/actions.rb +51 -3
  24. data/lib/utopia/controller/base.rb +72 -29
  25. data/lib/utopia/controller/middleware.rb +20 -15
  26. data/lib/utopia/controller/respond.rb +36 -32
  27. data/lib/utopia/controller/responder.rb +106 -45
  28. data/lib/utopia/controller/result.rb +11 -0
  29. data/lib/utopia/controller/rewrite.rb +39 -0
  30. data/lib/utopia/controller/variables.rb +15 -3
  31. data/lib/utopia/controller.rb +2 -0
  32. data/lib/utopia/exceptions/handler.rb +20 -11
  33. data/lib/utopia/exceptions/mailer.rb +56 -51
  34. data/lib/utopia/extensions/array_split.rb +6 -0
  35. data/lib/utopia/extensions/date_comparisons.rb +6 -0
  36. data/lib/utopia/http.rb +11 -48
  37. data/lib/utopia/import_map.rb +19 -11
  38. data/lib/utopia/localization/locales.rb +60 -0
  39. data/lib/utopia/localization/middleware.rb +107 -73
  40. data/lib/utopia/localization/preferences.rb +76 -0
  41. data/lib/utopia/localization/resolver.rb +47 -0
  42. data/lib/utopia/localization.rb +6 -0
  43. data/lib/utopia/middleware.rb +3 -4
  44. data/lib/utopia/path/matcher.rb +15 -0
  45. data/lib/utopia/path.rb +146 -10
  46. data/lib/utopia/redirection/client_redirect.rb +87 -0
  47. data/lib/utopia/redirection/directory_index.rb +41 -0
  48. data/lib/utopia/redirection/errors.rb +78 -0
  49. data/lib/utopia/redirection/moved.rb +52 -0
  50. data/lib/utopia/redirection/request_failure.rb +26 -0
  51. data/lib/utopia/redirection/rewrite.rb +42 -0
  52. data/lib/utopia/redirection.rb +7 -165
  53. data/lib/utopia/request.rb +202 -0
  54. data/lib/utopia/response.rb +73 -0
  55. data/lib/utopia/session/lazy_hash.rb +27 -1
  56. data/lib/utopia/session/middleware.rb +103 -30
  57. data/lib/utopia/session/serialization.rb +8 -0
  58. data/lib/utopia/session.rb +5 -0
  59. data/lib/utopia/setup.rb +23 -3
  60. data/lib/utopia/shell.rb +28 -7
  61. data/lib/utopia/static/local_file.rb +84 -61
  62. data/lib/utopia/static/middleware.rb +65 -33
  63. data/lib/utopia/static/mime_types.rb +38 -29
  64. data/lib/utopia/static.rb +2 -0
  65. data/lib/utopia/version.rb +3 -2
  66. data/lib/utopia.rb +1 -1
  67. data/license.md +1 -1
  68. data/readme.md +33 -4
  69. data/releases.md +15 -0
  70. data/setup/site/bake.rb +1 -1
  71. data/setup/site/config/application.rb +51 -0
  72. data/setup/site/config/serve.rb +8 -0
  73. data/setup/site/falcon.rb +17 -4
  74. data/setup/site/fixtures/website.rb +27 -11
  75. data/setup/site/gems.rb +1 -3
  76. data/setup/site/lib/readme.txt +1 -1
  77. data/setup/site/pages/welcome/index.xnode +3 -3
  78. data/setup/site/readme.md +0 -3
  79. data/setup/site/test/website.rb +2 -2
  80. data.tar.gz.sig +0 -0
  81. metadata +60 -34
  82. metadata.gz.sig +0 -0
  83. data/lib/utopia/localization/wrapper.rb +0 -52
  84. data/setup/site/Guardfile +0 -12
  85. data/setup/site/config.ru +0 -49
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Utopia
7
+ module Redirection
8
+ # An error handler fails to redirect to a valid page.
9
+ class RequestFailure < StandardError
10
+ # Describe a failed attempt to render an error document.
11
+ # @parameter resource_path [Object] The resource path.
12
+ # @parameter resource_status [Object] The resource status.
13
+ # @parameter error_path [Object] The error path.
14
+ # @parameter error_status [Object] The error status.
15
+ def initialize(resource_path, resource_status, error_path, error_status)
16
+ @resource_path = resource_path
17
+ @resource_status = resource_status
18
+
19
+ @error_path = error_path
20
+ @error_status = error_status
21
+
22
+ super "Requested resource #{@resource_path} resulted in a #{@resource_status} error. Requested error handler #{@error_path} resulted in a #{@error_status} error."
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "client_redirect"
7
+
8
+ module Utopia
9
+ module Redirection
10
+ # Rewrite requests that match the given pattern to a single destination.
11
+ class Rewrite < ClientRedirect
12
+ # Initialize exact-path redirections.
13
+ # @parameter app [Interface(:call)] The downstream application.
14
+ # @parameter patterns [Hash] The path rewrite patterns.
15
+ # @parameter status [Integer] The status.
16
+ def initialize(app, patterns, status: 301)
17
+ @patterns = patterns
18
+
19
+ super(app, status: status)
20
+ end
21
+
22
+ # Freeze this object and its internal state.
23
+ # @returns [self] This object.
24
+ def freeze
25
+ return self if frozen?
26
+
27
+ @patterns.freeze
28
+
29
+ return super
30
+ end
31
+
32
+ # Redirect a path found in the rewrite map.
33
+ # @parameter path [String] The normalized request path.
34
+ # @returns [Protocol::HTTP::Response | Nil] The redirect response when the path is mapped.
35
+ def [] path
36
+ if location = @patterns[path]
37
+ return redirect(location)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,169 +1,11 @@
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
- require_relative "middleware"
7
-
8
- module Utopia
9
- # A middleware which assists with redirecting from one path to another.
10
- module Redirection
11
- # An error handler fails to redirect to a valid page.
12
- class RequestFailure < StandardError
13
- def initialize(resource_path, resource_status, error_path, error_status)
14
- @resource_path = resource_path
15
- @resource_status = resource_status
16
-
17
- @error_path = error_path
18
- @error_status = error_status
19
-
20
- super "Requested resource #{@resource_path} resulted in a #{@resource_status} error. Requested error handler #{@error_path} resulted in a #{@error_status} error."
21
- end
22
- end
23
-
24
- # A middleware which performs internal redirects based on error status codes.
25
- class Errors
26
- # @param codes [Hash<Integer,String>] The redirection path for a given error code.
27
- def initialize(app, codes = {})
28
- @app = app
29
- @codes = codes
30
- end
31
-
32
- def freeze
33
- return self if frozen?
34
-
35
- @codes.freeze
36
-
37
- super
38
- end
39
-
40
- def unhandled_error?(response)
41
- response[0] >= 400 && response[1].empty?
42
- end
43
-
44
- def call(env)
45
- response = @app.call(env)
46
-
47
- if unhandled_error?(response) && location = @codes[response[0]]
48
- error_request = env.merge(Rack::PATH_INFO => location, Rack::REQUEST_METHOD => Rack::GET)
49
- error_response = @app.call(error_request)
50
-
51
- if error_response[0] >= 400
52
- raise RequestFailure.new(env[Rack::PATH_INFO], response[0], location, error_response[0])
53
- else
54
- # Feed the error code back with the error document:
55
- error_response[0] = response[0]
56
- return error_response
57
- end
58
- else
59
- return response
60
- end
61
- end
62
- end
63
-
64
- # We cache 301 redirects for 24 hours.
65
- DEFAULT_MAX_AGE = 3600*24
66
-
67
- # A basic client-side redirect.
68
- class ClientRedirect
69
- def initialize(app, status: 307, max_age: DEFAULT_MAX_AGE)
70
- @app = app
71
- @status = status
72
- @max_age = max_age
73
- end
74
-
75
- def freeze
76
- return self if frozen?
77
-
78
- @status.freeze
79
- @max_age.freeze
80
-
81
- super
82
- end
83
-
84
- attr :status
85
- attr :max_age
86
-
87
- def cache_control
88
- # http://jacquesmattheij.com/301-redirects-a-dangerous-one-way-street
89
- "max-age=#{self.max_age}"
90
- end
91
-
92
- def headers(location)
93
- {HTTP::LOCATION => location, HTTP::CACHE_CONTROL => self.cache_control}
94
- end
95
-
96
- def redirect(location)
97
- return [self.status, self.headers(location), []]
98
- end
99
-
100
- def [] path
101
- false
102
- end
103
-
104
- def call(env)
105
- path = env[Rack::PATH_INFO]
106
-
107
- if redirection = self[path]
108
- return redirection
109
- end
110
-
111
- return @app.call(env)
112
- end
113
- end
114
-
115
- # Redirect urls that end with a `/`, e.g. directories.
116
- class DirectoryIndex < ClientRedirect
117
- def initialize(app, index: "index")
118
- @app = app
119
- @index = index
120
-
121
- super(app)
122
- end
123
-
124
- def [] path
125
- if path.end_with?("/")
126
- return redirect(path + @index)
127
- end
128
- end
129
- end
130
-
131
- # Rewrite requests that match the given pattern to a single destination.
132
- class Rewrite < ClientRedirect
133
- def initialize(app, patterns, status: 301)
134
- @patterns = patterns
135
-
136
- super(app, status: status)
137
- end
138
-
139
- def [] path
140
- if location = @patterns[path]
141
- return redirect(location)
142
- end
143
- end
144
- end
145
-
146
- # Rewrite requests that match the given pattern to a new prefix.
147
- class Moved < ClientRedirect
148
- def initialize(app, pattern, prefix, status: 301, flatten: false)
149
- @app = app
150
-
151
- @pattern = pattern
152
- @prefix = prefix
153
- @flatten = flatten
154
-
155
- super(app, status: status)
156
- end
157
-
158
- def [] path
159
- if path.start_with?(@pattern)
160
- if @flatten
161
- return redirect(@prefix)
162
- else
163
- return redirect(path.sub(@pattern, @prefix))
164
- end
165
- end
166
- end
167
- end
168
- end
169
- end
6
+ require_relative "redirection/request_failure"
7
+ require_relative "redirection/errors"
8
+ require_relative "redirection/client_redirect"
9
+ require_relative "redirection/directory_index"
10
+ require_relative "redirection/rewrite"
11
+ require_relative "redirection/moved"
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "stringio"
7
+ require "protocol/http/request"
8
+ require "protocol/url"
9
+ require "protocol/url/form_data/parser"
10
+
11
+ require_relative "path"
12
+
13
+ module Utopia
14
+ # Utopia's application-facing request wrapper.
15
+ #
16
+ # Protocol request methods are delegated to the underlying request; parsing
17
+ # and application conveniences live here rather than on protocol-http itself.
18
+ class Request
19
+ # Build a Utopia request from the given protocol request arguments.
20
+ def self.[](*arguments)
21
+ self.new(Protocol::HTTP::Request[*arguments])
22
+ end
23
+
24
+ # Initialize the request.
25
+ # @parameter delegate [Protocol::HTTP::Request] The underlying protocol request.
26
+ def initialize(delegate)
27
+ @delegate = delegate
28
+
29
+ @url = nil
30
+ @session = nil
31
+ @variables = nil
32
+ @localization = nil
33
+ @exception = nil
34
+
35
+ @query_parameters = nil
36
+ @cookies = nil
37
+ end
38
+
39
+ # The underlying protocol request.
40
+ attr :delegate
41
+
42
+ # The session associated with this request, if installed.
43
+ attr_accessor :session
44
+
45
+ # The controller variables associated with this request, if installed.
46
+ attr_accessor :variables
47
+
48
+ # The immutable localization preferences associated with this request, if installed.
49
+ attr_accessor :localization
50
+
51
+ # The exception associated with this request, if any.
52
+ attr_accessor :exception
53
+
54
+ private def parse_url
55
+ path, query = @delegate.path.split("?", 2)
56
+
57
+ if scheme = @delegate.scheme and authority = @delegate.authority
58
+ Protocol::URL::Absolute.new(scheme, authority, path, query)
59
+ else
60
+ Protocol::URL::Relative.new(path, query)
61
+ end
62
+ end
63
+
64
+ # The structured request URL.
65
+ # @returns [Protocol::URL::Absolute | Protocol::URL::Relative | Nil] The request URL.
66
+ def url
67
+ @url ||= self.parse_url.normalize!
68
+ end
69
+
70
+ # @returns [Utopia::Path] The path components.
71
+ def path
72
+ Path.new(self.url.path.components(Protocol::URL::Encoding::System))
73
+ end
74
+
75
+ # Assign the decoded application path while preserving the current query string.
76
+ # @parameter value [Utopia::Path | Protocol::URL::Path | String] The path.
77
+ def path= value
78
+ if value.is_a?(Protocol::URL::Path)
79
+ self.url.path = value
80
+ else
81
+ self.url.path = Path[value].to_url_path
82
+ end
83
+ end
84
+
85
+ # Whether the request method is QUERY.
86
+ # @returns [Boolean] Whether the request method is QUERY.
87
+ def query?
88
+ @delegate.method == "QUERY"
89
+ end
90
+
91
+ # Whether the request method is POST.
92
+ def post?
93
+ @delegate.method == "POST"
94
+ end
95
+
96
+ # The normalized original request path, before any internal request rewrites.
97
+ # @returns [Utopia::Path] The decoded application path.
98
+ def request_path
99
+ path = @delegate.path.split("?", 2).first
100
+ path = Protocol::URL::Path[path].normalize.simplify
101
+
102
+ return Path.new(path.components(Protocol::URL::Encoding::System))
103
+ end
104
+
105
+ QUERY_PARSER = Protocol::URL::FormData::Parser.new
106
+
107
+ private def parse_query_parameters(query)
108
+ if query
109
+ return QUERY_PARSER.parse(StringIO.new(query))
110
+ else
111
+ Hash.new
112
+ end
113
+ end
114
+
115
+ # Decoded query arguments.
116
+ def query_parameters
117
+ @query_parameters ||= parse_query_parameters(self.url.query)
118
+ end
119
+
120
+ private def parse_cookies(cookie_header)
121
+ cookies = {}
122
+
123
+ return cookies unless cookie_header
124
+
125
+ if cookie_header.respond_to?(:to_str)
126
+ cookie_header = cookie_header.to_str
127
+ else
128
+ cookie_header = cookie_header.to_s
129
+ end
130
+
131
+ cookie_header.split(/;\s*/).each do |pair|
132
+ key, value = pair.split("=", 2)
133
+ cookies[key] = value || ""
134
+ end
135
+
136
+ return cookies
137
+ end
138
+
139
+ # Decoded request cookies.
140
+ def cookies
141
+ @cookies ||= parse_cookies(self.headers["cookie"])
142
+ end
143
+
144
+ # The request user agent.
145
+ def user_agent
146
+ @delegate.headers["user-agent"]
147
+ end
148
+
149
+ # The request referrer.
150
+ def referrer
151
+ @delegate.headers["referer"]
152
+ end
153
+
154
+ # The remote peer IP address, if available.
155
+ def ip
156
+ @delegate.peer&.ip_address
157
+ end
158
+
159
+ # Assign the structured request URL.
160
+ # @parameter url [Protocol::URL::Absolute | Protocol::URL::Relative | String | Nil] The request URL.
161
+ def url= url
162
+ @url = Protocol::URL[url]
163
+ @query_parameters = nil
164
+ end
165
+
166
+ # Build a derived request with updated protocol fields.
167
+ def with(method: nil, path: self.path)
168
+ delegate = @delegate
169
+
170
+ if method
171
+ delegate = @delegate.dup
172
+ delegate.method = method
173
+ end
174
+
175
+ request = self.class.new(delegate)
176
+ request.session = @session
177
+ request.variables = @variables
178
+ request.localization = @localization
179
+ request.exception = @exception
180
+ request.path = path
181
+
182
+ return request
183
+ end
184
+
185
+ private
186
+
187
+ # These inherited methods conflict with the protocol request interface, so remove them to allow delegation.
188
+ undef_method :method, :to_s
189
+
190
+ def method_missing(name, ...)
191
+ if @delegate.respond_to?(name)
192
+ @delegate.public_send(name, ...)
193
+ else
194
+ super
195
+ end
196
+ end
197
+
198
+ def respond_to_missing?(name, include_private = false)
199
+ @delegate.respond_to?(name) || super(name, include_private)
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/http/response"
7
+ require "protocol/http/middleware"
8
+
9
+ module Utopia
10
+ # Response helpers for Utopia applications.
11
+ #
12
+ # The canonical transport response remains {Protocol::HTTP::Response}. This
13
+ # module provides convenience constructors and normalization at the application
14
+ # boundary.
15
+ module Response
16
+ CONTENT_TYPE = "content-type".freeze
17
+ LOCATION = "location".freeze
18
+
19
+ NotFound = Protocol::HTTP::Middleware::NotFound
20
+
21
+ # Build a protocol HTTP response.
22
+ # @parameter status [Integer] The HTTP status code.
23
+ # @parameter headers [Hash | Protocol::HTTP::Headers | Nil] The response headers.
24
+ # @parameter body [Object] The response body.
25
+ # @parameter options [Hash] Additional options passed to `Protocol::HTTP::Response[]`.
26
+ # @returns [Protocol::HTTP::Response] The response object.
27
+ def self.[](status, headers = nil, body = nil, **options)
28
+ Protocol::HTTP::Response[status, headers, body, **options]
29
+ end
30
+
31
+ # Normalize a response-like value to a protocol response.
32
+ # @parameter response [Object] The response-like value.
33
+ # @returns [Protocol::HTTP::Response] The normalized response.
34
+ # @raises [TypeError] If the response cannot be normalized.
35
+ def self.wrap(response)
36
+ return response if response.is_a?(Protocol::HTTP::Response)
37
+
38
+ if response.respond_to?(:to_response)
39
+ response = response.to_response
40
+ return response if response.is_a?(Protocol::HTTP::Response)
41
+ end
42
+
43
+ raise TypeError, "Expected a Protocol::HTTP::Response, but got #{response.class}!"
44
+ end
45
+
46
+ # Build a redirect response.
47
+ # @parameter location [String] The redirect location.
48
+ # @parameter status [Integer] The redirect status code.
49
+ # @parameter headers [Hash] Additional response headers.
50
+ # @returns [Protocol::HTTP::Response] The redirect response.
51
+ def self.redirect(location, status = 302, headers = {})
52
+ self[status, headers.merge(LOCATION => location), []]
53
+ end
54
+
55
+ # Build a plain text response.
56
+ # @parameter content [String] The response content.
57
+ # @parameter status [Integer] The response status.
58
+ # @parameter headers [Hash] Additional response headers.
59
+ # @returns [Protocol::HTTP::Response] The text response.
60
+ def self.text(content, status = 200, headers = {})
61
+ self[status, {CONTENT_TYPE => "text/plain; charset=utf-8"}.merge(headers), [content]]
62
+ end
63
+
64
+ # Build an HTML response.
65
+ # @parameter content [String] The response content.
66
+ # @parameter status [Integer] The response status.
67
+ # @parameter headers [Hash] Additional response headers.
68
+ # @returns [Protocol::HTTP::Response] The HTML response.
69
+ def self.html(content, status = 200, headers = {})
70
+ self[status, {CONTENT_TYPE => "text/html; charset=utf-8"}.merge(headers), [content]]
71
+ end
72
+ end
73
+ end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2014-2022, by Samuel Williams.
4
+ # Copyright, 2014-2026, by Samuel Williams.
5
5
 
6
6
  module Utopia
7
7
  module Session
8
8
  # A simple hash table which fetches it's values only when required.
9
9
  class LazyHash
10
+ # Initialize a lazily loaded hash.
11
+ # @yields The block which loads the initial values.
10
12
  def initialize(&block)
11
13
  @changed = false
12
14
  @values = nil
@@ -14,12 +16,21 @@ module Utopia
14
16
  @loader = block
15
17
  end
16
18
 
19
+ # The loaded session values, if already loaded.
20
+ # @returns [Hash | Nil] The loaded values.
17
21
  attr :values
18
22
 
23
+ # Fetch a value by key, loading the hash if necessary.
24
+ # @parameter key [Object] The key.
25
+ # @returns [Object | Nil] The value.
19
26
  def [] key
20
27
  load![key]
21
28
  end
22
29
 
30
+ # Store a value by key.
31
+ # @parameter key [Object] The key.
32
+ # @parameter value [Object] The value.
33
+ # @returns [Object] The stored value.
23
34
  def []= key, value
24
35
  values = load!
25
36
 
@@ -31,10 +42,16 @@ module Utopia
31
42
  return value
32
43
  end
33
44
 
45
+ # Check whether the hash contains a key.
46
+ # @parameter key [Object] The key.
47
+ # @returns [Boolean] Whether the key exists.
34
48
  def include?(key)
35
49
  load!.include?(key)
36
50
  end
37
51
 
52
+ # Delete a value by key.
53
+ # @parameter key [Object] The key.
54
+ # @returns [Object | Nil] The deleted value.
38
55
  def delete(key)
39
56
  load!
40
57
 
@@ -43,18 +60,27 @@ module Utopia
43
60
  @values.delete(key)
44
61
  end
45
62
 
63
+ # Check whether any value has changed.
64
+ # @returns [Boolean] Whether the hash has changed.
46
65
  def changed?
47
66
  @changed
48
67
  end
49
68
 
69
+ # Load and return the underlying values.
70
+ # @returns [Hash] The loaded values.
50
71
  def load!
51
72
  @values ||= @loader.call
52
73
  end
53
74
 
75
+ # Check whether the underlying values have been loaded.
76
+ # @returns [Boolean] Whether the values are loaded.
54
77
  def loaded?
55
78
  !@values.nil?
56
79
  end
57
80
 
81
+ # Check whether the values should be persisted.
82
+ # @parameter timeout [Numeric | Nil] The maximum age before an update is required.
83
+ # @returns [Boolean] Whether an update is required.
58
84
  def needs_update?(timeout = nil)
59
85
  # If data has changed, we need update:
60
86
  return true if @changed