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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Utopia
7
+ module Localization
8
+ # Matches configured locales against language ranges.
9
+ class Locales
10
+ # Expand a locale into progressively less specific language ranges.
11
+ # @parameter locale [String] The locale to expand.
12
+ # @parameter patterns [Hash] The destination language-range mapping.
13
+ def self.expand(locale, patterns)
14
+ parts = locale.split("-")
15
+
16
+ while parts.any?
17
+ pattern = parts.join("-")
18
+ patterns[pattern] ||= locale
19
+ parts.pop
20
+ end
21
+ end
22
+
23
+ # Initialize the configured locales.
24
+ # @parameter names [Array(String)] The locale names, in preference order.
25
+ def initialize(names)
26
+ @names = names
27
+ @patterns = {}
28
+
29
+ @names.each do |name|
30
+ self.class.expand(name, @patterns)
31
+ end
32
+
33
+ freeze
34
+ end
35
+
36
+ # Freeze this object and its internal state.
37
+ # @returns [self] This object.
38
+ def freeze
39
+ return self if frozen?
40
+
41
+ @names.freeze
42
+ @patterns.freeze
43
+
44
+ return super
45
+ end
46
+
47
+ attr :names
48
+ attr :patterns
49
+
50
+ # Select configured locales matching the given language ranges.
51
+ # @parameter languages [Enumerable] Preferred language ranges.
52
+ # @returns [Array(String)] Matching locale names in language preference order.
53
+ def match(languages)
54
+ languages.filter_map do |language|
55
+ @patterns[language.name]
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,26 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2009-2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
- require_relative "wrapper"
6
+ require_relative "preferences"
7
+ require_relative "locales"
8
+ require_relative "../middleware"
9
+ require_relative "../request"
10
+ require_relative "../response"
11
+
12
+ require "set"
13
+ require "protocol/http/header/accept_language"
7
14
 
8
15
  module Utopia
9
16
  module Localization
10
- class Middleware
11
- RESOURCE_NOT_FOUND = [400, {}, []].freeze
12
-
13
- HTTP_ACCEPT_LANGUAGE = "HTTP_ACCEPT_LANGUAGE".freeze
14
-
17
+ # Computes localization preferences and rewrites locale-prefixed paths.
18
+ class Middleware < Protocol::HTTP::Middleware
15
19
  # @param locales [Array<String>] An array of all supported locales.
16
20
  # @param default_locale [String] The default locale if none is provided.
17
- # @param default_locales [String] The locales to try in order if none is provided.
18
- # @param hosts [Hash<Pattern, String>] Specify a mapping of the HTTP_HOST header to a given locale.
19
- # @param ignore [Array<Pattern>] A list of patterns matched against PATH_INFO which will not be localized.
21
+ # @param default_locales [Array<String | Nil>] The locales to try in order if none is provided.
22
+ # @param hosts [Hash<Pattern, String>] Specify a mapping of request hosts to locales.
23
+ # @param ignore [Array<Pattern>] A list of patterns matched against request paths which will not be localized.
20
24
  def initialize(app, locales:, default_locale: nil, default_locales: nil, hosts: {}, ignore: [])
21
- @app = app
25
+ super(app)
22
26
 
23
- @all_locales = HTTP::Accept::Languages::Locales.new(locales)
27
+ @all_locales = Locales.new(locales)
24
28
 
25
29
  # Locales here are represented as an array of strings, e.g. ['en', 'ja', 'cn', 'de'] and are used in order if no locale is specified by the user.
26
30
  unless @default_locales = default_locales
@@ -41,11 +45,11 @@ module Utopia
41
45
  # Select a localization based on a request host name:
42
46
  @hosts = hosts
43
47
 
44
- @ignore = ignore || options[:nonlocalized]
45
-
46
- @methods = methods
48
+ @ignore = ignore
47
49
  end
48
50
 
51
+ # Freeze this object and its internal state.
52
+ # @returns [self] This object.
49
53
  def freeze
50
54
  return self if frozen?
51
55
 
@@ -61,112 +65,142 @@ module Utopia
61
65
  attr :all_locales
62
66
  attr :default_locale
63
67
 
64
- def preferred_locales(env)
65
- return to_enum(:preferred_locales, env) unless block_given?
66
-
68
+ # Compute the preferred locales for a request.
69
+ # @parameter request [Utopia::Request] The application request.
70
+ # @parameter path_locale [String | Nil] The locale extracted from the request path.
71
+ # @returns [Array(String | Nil)] The unique locales in preference order.
72
+ def preferred_locales(request, path_locale = nil)
67
73
  # Keep track of what locales have been tried:
68
74
  locales = Set.new
69
75
 
70
- host_preferred_locales(env) do |locale|
71
- yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
76
+ if path_locale
77
+ locales.add(path_locale)
72
78
  end
73
79
 
74
- request_preferred_locale(env) do |locale, path|
75
- # We have extracted a locale from the path, so from this point on we should use the updated path:
76
- env = env.merge(Rack::PATH_INFO => path.to_s)
77
-
78
- yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
80
+ host_preferred_locales(request) do |locale|
81
+ locales.add(locale)
79
82
  end
80
83
 
81
- browser_preferred_locales(env).each do |locale|
82
- yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
84
+ browser_preferred_locales(request).each do |locale|
85
+ locales.add(locale)
83
86
  end
84
87
 
85
88
  @default_locales.each do |locale|
86
- yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
89
+ locales.add(locale)
87
90
  end
91
+
92
+ return locales.to_a
88
93
  end
89
94
 
90
- def host_preferred_locales(env)
91
- http_host = env[Rack::HTTP_HOST]
92
-
93
- # Yield all hosts which match the incoming http_host:
95
+ # Infer preferred locales from the request authority.
96
+ # @parameter request [Utopia::Request] The application request.
97
+ # @yields {|locale| ...} Each locale whose host pattern matches the request authority.
98
+ # @returns [Hash] The configured host mappings.
99
+ def host_preferred_locales(request)
100
+ authority = request.authority.to_s
101
+
102
+ # Yield all hosts which match the incoming authority:
94
103
  @hosts.each do |pattern, locale|
95
- yield locale if http_host[pattern]
104
+ if authority[pattern]
105
+ yield locale
106
+ end
96
107
  end
97
108
  end
98
109
 
99
- def request_preferred_locale(env)
100
- path = Path[env[Rack::PATH_INFO]]
110
+ # Extract a locale prefix from the request path.
111
+ # @parameter request [Utopia::Request] The application request.
112
+ # @returns [Array(Utopia::Request, String | Nil)] The request and extracted locale.
113
+ def extract_path_locale(request)
114
+ path = request.url.path
115
+
116
+ # Localization prefixes only apply to absolute application paths:
117
+ unless path.absolute?
118
+ return request, nil
119
+ end
101
120
 
102
- if request_locale = @all_locales.patterns[path.first]
103
- # Remove the localization prefix:
104
- path.delete_at(0)
121
+ if segment = path.segments[1]
122
+ # Decode only the component which may contain the locale:
123
+ component = Protocol::URL::Encoding::System.unescape(segment)
105
124
 
106
- yield request_locale, path
125
+ if request_locale = @all_locales.patterns[component]
126
+ # Remove the locale while preserving all other encoded segments:
127
+ segments = path.segments.dup
128
+ segments.delete_at(1)
129
+
130
+ # Preserve the absolute root when the locale was the only component:
131
+ if segments == [""]
132
+ segments << ""
133
+ end
134
+
135
+ path = Protocol::URL::Path.new(nil, segments)
136
+
137
+ return request.with(path: path), request_locale
138
+ end
107
139
  end
140
+
141
+ return request, nil
108
142
  end
109
143
 
110
- def browser_preferred_locales(env)
111
- accept_languages = env[HTTP_ACCEPT_LANGUAGE]
144
+ # Parse the locales preferred by the browser.
145
+ # @parameter request [Utopia::Request] The application request.
146
+ # @returns [Array(String)] Supported locales accepted by the browser, in preference order.
147
+ def browser_preferred_locales(request)
148
+ accept_languages = request.headers["accept-language"]
112
149
 
113
150
  # No user prefered languages:
114
151
  return [] unless accept_languages
115
152
 
116
153
  # Extract the ordered list of languages:
117
- languages = HTTP::Accept::Languages.parse(accept_languages)
154
+ languages = accept_languages.preferred_languages
118
155
 
119
156
  # Returns available languages based on the order languages:
120
- return @all_locales & languages
121
- rescue HTTP::Accept::ParseError
157
+ return @all_locales.match(languages)
158
+ rescue Protocol::HTTP::Header::AcceptLanguage::ParseError
122
159
  # If we fail to parse the browser Accept-Language header, we ignore it (silently).
123
160
  return []
124
161
  end
125
162
 
126
- def localized?(env)
163
+ # Check whether the request path includes a locale.
164
+ # @parameter request [Utopia::Request] The application request.
165
+ # @returns [Boolean] Whether the path is eligible for localization.
166
+ def localized?(request)
127
167
  # Ignore requests which match the ignored paths:
128
- path_info = env[Rack::PATH_INFO]
129
- return false if @ignore.any?{|pattern| path_info[pattern] != nil}
168
+ path = request.url.path.encoded
169
+ return false if @ignore.any?{|pattern| path[pattern] != nil}
130
170
 
131
171
  return true
132
172
  end
133
173
 
134
- # Set the Vary: header on the response to indicate that this response should include the header in the cache key.
135
- def vary(env, response)
136
- headers = response[1].to_a
174
+ # Mark the response as varying by language.
175
+ # @parameter response [Protocol::HTTP::Response] The response.
176
+ # @returns [Protocol::HTTP::Response] The response with localization headers.
177
+ def vary(response)
178
+ response = Response.wrap(response)
179
+ headers = response.headers
137
180
 
138
181
  # This response was based on the Accept-Language header:
139
- headers << ["Vary", "Accept-Language"]
140
-
141
- # Althought this header is generally not supported, we supply it anyway as it is useful for debugging:
142
- if locale = env[CURRENT_LOCALE_KEY]
143
- # Set the Content-Location to point to the localized URI as requested:
144
- headers["Content-Location"] = "/#{locale}" + env[Rack::PATH_INFO]
145
- end
182
+ headers.add("vary", "Accept-Language")
146
183
 
147
184
  return response
148
185
  end
149
186
 
150
- def call(env)
187
+ # Attach localization preferences and invoke the application once.
188
+ # @parameter request [Utopia::Request] The request.
189
+ # @returns [Protocol::HTTP::Response] The response with cache-variation headers.
190
+ def call(request)
151
191
  # Pass the request through if it shouldn't be localized:
152
- return @app.call(env) unless localized?(env)
153
-
154
- env[LOCALIZATION_KEY] = self
192
+ return @delegate.call(request) unless localized?(request)
155
193
 
156
- response = nil
194
+ request, path_locale = extract_path_locale(request)
195
+ locales = preferred_locales(request, path_locale)
157
196
 
158
- # We have a non-localized request, but there might be a localized resource. We return the best localization possible:
159
- preferred_locales(env) do |localized_env|
160
- # puts "Trying locale: #{localized_env[CURRENT_LOCALE_KEY]}: #{localized_env[Rack::PATH_INFO]}..."
161
-
162
- response = @app.call(localized_env)
163
-
164
- break unless response[0] >= 400
165
-
166
- response[2].close if response[2].respond_to?(:close)
167
- end
197
+ request.localization = Preferences.new(
198
+ all_locales: @all_locales.names,
199
+ preferred_locales: locales,
200
+ default_locale: @default_locale,
201
+ )
168
202
 
169
- return vary(env, response)
203
+ return vary(@delegate.call(request))
170
204
  end
171
205
  end
172
206
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Utopia
7
+ module Localization
8
+ # Immutable localization preferences for a request or resolved resource.
9
+ class Preferences
10
+ # Initialize localization preferences.
11
+ # @parameter all_locales [Array(String)] All configured locales.
12
+ # @parameter preferred_locales [Array(String | Nil)] Locales in resolution order.
13
+ # @parameter default_locale [String | Nil] The configured default locale.
14
+ # @parameter locale [String | Nil] The currently selected locale.
15
+ def initialize(all_locales:, preferred_locales:, default_locale:, locale: preferred_locales.first)
16
+ @all_locales = all_locales
17
+ @preferred_locales = preferred_locales
18
+ @default_locale = default_locale
19
+ @locale = locale
20
+
21
+ freeze
22
+ end
23
+
24
+ # Freeze this object and its internal state.
25
+ # @returns [self] This object.
26
+ def freeze
27
+ return self if frozen?
28
+
29
+ @all_locales.each(&:freeze)
30
+ @preferred_locales.each(&:freeze)
31
+ @default_locale.freeze
32
+ @locale.freeze
33
+
34
+ @all_locales.freeze
35
+ @preferred_locales.freeze
36
+
37
+ return super
38
+ end
39
+
40
+ attr :all_locales
41
+ attr :preferred_locales
42
+ attr :default_locale
43
+ attr :locale
44
+
45
+ # Whether localization is active.
46
+ # @returns [Boolean] `true` when locales are configured.
47
+ def localized?
48
+ !@all_locales.empty?
49
+ end
50
+
51
+ # Build a path with the given locale prefix.
52
+ # @parameter path [Utopia::Path | String] The path.
53
+ # @parameter locale [String | Nil] The locale.
54
+ # @returns [String] The locale-prefixed path.
55
+ def localized_path(path, locale = @locale)
56
+ if locale
57
+ return "/#{locale}#{path}"
58
+ else
59
+ return path.to_s
60
+ end
61
+ end
62
+
63
+ # Select a locale without changing the original preferences.
64
+ # @parameter locale [String | Nil] The selected locale.
65
+ # @returns [Preferences] A new localization preference object.
66
+ def with(locale:)
67
+ self.class.new(
68
+ all_locales: @all_locales,
69
+ preferred_locales: @preferred_locales,
70
+ default_locale: @default_locale,
71
+ locale: locale,
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../response"
7
+
8
+ module Utopia
9
+ module Localization
10
+ # Resolves localized resources from immutable request preferences.
11
+ module Resolver
12
+ CONTENT_LANGUAGE = "content-language".freeze
13
+ CONTENT_LOCATION = "content-location".freeze
14
+
15
+ private
16
+
17
+ # Resolve a response by trying each preferred locale in order.
18
+ def resolve_localized(request)
19
+ if localization = request.localization
20
+ localization.preferred_locales.each do |locale|
21
+ selected = localization.with(locale: locale)
22
+
23
+ if response = yield(selected)
24
+ return localized_response(request, response, selected)
25
+ end
26
+ end
27
+ else
28
+ return yield(nil)
29
+ end
30
+
31
+ return nil
32
+ end
33
+
34
+ # Describe the selected localized representation in its response metadata.
35
+ def localized_response(request, response, localization)
36
+ response = Response.wrap(response)
37
+
38
+ if locale = localization.locale
39
+ response.headers[CONTENT_LANGUAGE] = locale
40
+ response.headers[CONTENT_LOCATION] = localization.localized_path(request.url.path.encoded)
41
+ end
42
+
43
+ return response
44
+ end
45
+ end
46
+ end
47
+ end
@@ -3,10 +3,16 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2009-2025, by Samuel Williams.
5
5
 
6
+ require_relative "localization/preferences"
7
+ require_relative "localization/locales"
8
+ require_relative "localization/resolver"
6
9
  require_relative "localization/middleware"
7
10
 
8
11
  module Utopia
12
+ # Computes request localization preferences and resolves localized resources.
9
13
  module Localization
14
+ # Construct localization middleware.
15
+ # @returns [Localization::Middleware] The localization middleware.
10
16
  def self.new(...)
11
17
  Middleware.new(...)
12
18
  end
@@ -1,7 +1,9 @@
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
+
6
+ require "protocol/http/middleware"
5
7
 
6
8
  require_relative "http"
7
9
  require_relative "path"
@@ -10,9 +12,6 @@ module Utopia
10
12
  # The default pages path for {Utopia::Content} middleware.
11
13
  PAGES_PATH = "pages".freeze
12
14
 
13
- # This is used for shared controller variables which get consumed by the content middleware.
14
- VARIABLES_KEY = "utopia.variables".freeze
15
-
16
15
  # The default root directory for middleware to operate within, e.g. the web-site directory. Convention over configuration.
17
16
  # @param subdirectory [String] Appended to the default root to make a more specific path.
18
17
  # @param pwd [String] The working directory for the current site.
@@ -15,6 +15,9 @@ module Utopia
15
15
  class Matcher
16
16
  # The result of matching against a {Path}.
17
17
  class MatchData
18
+ # Initialize the named captures and unmatched path suffix.
19
+ # @parameter named_parts [Hash] The named match captures.
20
+ # @parameter post_match [String] The unmatched path suffix.
18
21
  def initialize(named_parts, post_match)
19
22
  @named_parts = named_parts
20
23
  @post_match = Path[post_match]
@@ -26,10 +29,15 @@ module Utopia
26
29
  # Any remaining part past the end of the explicitly matched components.
27
30
  attr :post_match
28
31
 
32
+ # Fetch a named capture.
33
+ # @parameter key [String | Symbol] The lookup key.
34
+ # @returns [Object | Nil] The named captured value.
29
35
  def [] key
30
36
  @named_parts[key]
31
37
  end
32
38
 
39
+ # Return the named captures.
40
+ # @returns [Array(String)] The capture names.
33
41
  def names
34
42
  @named_parts.keys
35
43
  end
@@ -40,10 +48,17 @@ module Utopia
40
48
  @patterns = patterns
41
49
  end
42
50
 
51
+ # Construct a matcher from typed named patterns.
52
+ # @parameter patterns [Hash] The path rewrite patterns.
53
+ # @returns [Matcher] The matcher.
43
54
  def self.[](patterns)
44
55
  self.new(patterns)
45
56
  end
46
57
 
58
+ # Coerce.
59
+ # @parameter klass [Class] The class to configure.
60
+ # @parameter value [String] The captured text.
61
+ # @returns [Object] The captured text coerced to `klass`.
47
62
  def coerce(klass, value)
48
63
  if klass == Integer
49
64
  Integer(value)