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
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2009-2025, by Samuel Williams.
5
+
6
+ require_relative "wrapper"
7
+
8
+ module Utopia
9
+ module Localization
10
+ class Middleware
11
+ RESOURCE_NOT_FOUND = [400, {}, []].freeze
12
+
13
+ HTTP_ACCEPT_LANGUAGE = "HTTP_ACCEPT_LANGUAGE".freeze
14
+
15
+ # @param locales [Array<String>] An array of all supported locales.
16
+ # @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.
20
+ def initialize(app, locales:, default_locale: nil, default_locales: nil, hosts: {}, ignore: [])
21
+ @app = app
22
+
23
+ @all_locales = HTTP::Accept::Languages::Locales.new(locales)
24
+
25
+ # 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
+ unless @default_locales = default_locales
27
+ if default_locale
28
+ @default_locales = [default_locale, nil]
29
+ else
30
+ # We append nil, i.e. no localization.
31
+ @default_locales = @all_locales.names + [nil]
32
+ end
33
+ end
34
+
35
+ @default_locale = default_locale || @default_locales.first
36
+
37
+ unless @default_locales.include? @default_locale
38
+ @default_locales.unshift(@default_locale)
39
+ end
40
+
41
+ # Select a localization based on a request host name:
42
+ @hosts = hosts
43
+
44
+ @ignore = ignore || options[:nonlocalized]
45
+
46
+ @methods = methods
47
+ end
48
+
49
+ def freeze
50
+ return self if frozen?
51
+
52
+ @all_locales.freeze
53
+ @default_locales.freeze
54
+ @default_locale.freeze
55
+ @hosts.freeze
56
+ @ignore.freeze
57
+
58
+ super
59
+ end
60
+
61
+ attr :all_locales
62
+ attr :default_locale
63
+
64
+ def preferred_locales(env)
65
+ return to_enum(:preferred_locales, env) unless block_given?
66
+
67
+ # Keep track of what locales have been tried:
68
+ locales = Set.new
69
+
70
+ host_preferred_locales(env) do |locale|
71
+ yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
72
+ end
73
+
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
79
+ end
80
+
81
+ browser_preferred_locales(env).each do |locale|
82
+ yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
83
+ end
84
+
85
+ @default_locales.each do |locale|
86
+ yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
87
+ end
88
+ end
89
+
90
+ def host_preferred_locales(env)
91
+ http_host = env[Rack::HTTP_HOST]
92
+
93
+ # Yield all hosts which match the incoming http_host:
94
+ @hosts.each do |pattern, locale|
95
+ yield locale if http_host[pattern]
96
+ end
97
+ end
98
+
99
+ def request_preferred_locale(env)
100
+ path = Path[env[Rack::PATH_INFO]]
101
+
102
+ if request_locale = @all_locales.patterns[path.first]
103
+ # Remove the localization prefix:
104
+ path.delete_at(0)
105
+
106
+ yield request_locale, path
107
+ end
108
+ end
109
+
110
+ def browser_preferred_locales(env)
111
+ accept_languages = env[HTTP_ACCEPT_LANGUAGE]
112
+
113
+ # No user prefered languages:
114
+ return [] unless accept_languages
115
+
116
+ # Extract the ordered list of languages:
117
+ languages = HTTP::Accept::Languages.parse(accept_languages)
118
+
119
+ # Returns available languages based on the order languages:
120
+ return @all_locales & languages
121
+ rescue HTTP::Accept::ParseError
122
+ # If we fail to parse the browser Accept-Language header, we ignore it (silently).
123
+ return []
124
+ end
125
+
126
+ def localized?(env)
127
+ # 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}
130
+
131
+ return true
132
+ end
133
+
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
137
+
138
+ # 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
146
+
147
+ return response
148
+ end
149
+
150
+ def call(env)
151
+ # Pass the request through if it shouldn't be localized:
152
+ return @app.call(env) unless localized?(env)
153
+
154
+ env[LOCALIZATION_KEY] = self
155
+
156
+ response = nil
157
+
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
168
+
169
+ return vary(env, response)
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2009-2025, by Samuel Williams.
5
+
6
+ require_relative "middleware"
7
+
8
+ module Utopia
9
+ # A middleware which attempts to find localized content.
10
+ module Localization
11
+ LOCALIZATION_KEY = "utopia.localization".freeze
12
+ CURRENT_LOCALE_KEY = "utopia.localization.current_locale".freeze
13
+
14
+ # A wrapper to provide easy access to locale related data in the request.
15
+ class Wrapper
16
+ def initialize(env)
17
+ @env = env
18
+ end
19
+
20
+ def localization
21
+ @env[LOCALIZATION_KEY]
22
+ end
23
+
24
+ def localized?
25
+ localization != nil
26
+ end
27
+
28
+ # Returns the current locale or nil if not localized.
29
+ def current_locale
30
+ @env[CURRENT_LOCALE_KEY]
31
+ end
32
+
33
+ # Returns the default locale or nil if not localized.
34
+ def default_locale
35
+ localization && localization.default_locale
36
+ end
37
+
38
+ # Returns an empty array if not localized.
39
+ def all_locales
40
+ localization && localization.all_locales || []
41
+ end
42
+
43
+ def localized_path(path, locale)
44
+ "/#{locale}#{path}"
45
+ end
46
+ end
47
+
48
+ def self.[] request
49
+ Wrapper.new(request.env)
50
+ end
51
+ end
52
+ end
@@ -3,210 +3,12 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2009-2025, by Samuel Williams.
5
5
 
6
- require_relative "middleware"
6
+ require_relative "localization/middleware"
7
7
 
8
8
  module Utopia
9
- # A middleware which attempts to find localized content.
10
- class Localization
11
- # A wrapper to provide easy access to locale related data in the request.
12
- class Wrapper
13
- def initialize(env)
14
- @env = env
15
- end
16
-
17
- def localization
18
- @env[LOCALIZATION_KEY]
19
- end
20
-
21
- def localized?
22
- localization != nil
23
- end
24
-
25
- # Returns the current locale or nil if not localized.
26
- def current_locale
27
- @env[CURRENT_LOCALE_KEY]
28
- end
29
-
30
- # Returns the default locale or nil if not localized.
31
- def default_locale
32
- localization && localization.default_locale
33
- end
34
-
35
- # Returns an empty array if not localized.
36
- def all_locales
37
- localization && localization.all_locales || []
38
- end
39
-
40
- def localized_path(path, locale)
41
- "/#{locale}#{path}"
42
- end
43
- end
44
-
45
- def self.[] request
46
- Wrapper.new(request.env)
47
- end
48
-
49
- RESOURCE_NOT_FOUND = [400, {}, []].freeze
50
-
51
- HTTP_ACCEPT_LANGUAGE = "HTTP_ACCEPT_LANGUAGE".freeze
52
- LOCALIZATION_KEY = "utopia.localization".freeze
53
- CURRENT_LOCALE_KEY = "utopia.localization.current_locale".freeze
54
-
55
- # @param locales [Array<String>] An array of all supported locales.
56
- # @param default_locale [String] The default locale if none is provided.
57
- # @param default_locales [String] The locales to try in order if none is provided.
58
- # @param hosts [Hash<Pattern, String>] Specify a mapping of the HTTP_HOST header to a given locale.
59
- # @param ignore [Array<Pattern>] A list of patterns matched against PATH_INFO which will not be localized.
60
- def initialize(app, locales:, default_locale: nil, default_locales: nil, hosts: {}, ignore: [])
61
- @app = app
62
-
63
- @all_locales = HTTP::Accept::Languages::Locales.new(locales)
64
-
65
- # 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.
66
- unless @default_locales = default_locales
67
- if default_locale
68
- @default_locales = [default_locale, nil]
69
- else
70
- # We append nil, i.e. no localization.
71
- @default_locales = @all_locales.names + [nil]
72
- end
73
- end
74
-
75
- @default_locale = default_locale || @default_locales.first
76
-
77
- unless @default_locales.include? @default_locale
78
- @default_locales.unshift(@default_locale)
79
- end
80
-
81
- # Select a localization based on a request host name:
82
- @hosts = hosts
83
-
84
- @ignore = ignore || options[:nonlocalized]
85
-
86
- @methods = methods
87
- end
88
-
89
- def freeze
90
- return self if frozen?
91
-
92
- @all_locales.freeze
93
- @default_locales.freeze
94
- @default_locale.freeze
95
- @hosts.freeze
96
- @ignore.freeze
97
-
98
- super
99
- end
100
-
101
- attr :all_locales
102
- attr :default_locale
103
-
104
- def preferred_locales(env)
105
- return to_enum(:preferred_locales, env) unless block_given?
106
-
107
- # Keep track of what locales have been tried:
108
- locales = Set.new
109
-
110
- host_preferred_locales(env) do |locale|
111
- yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
112
- end
113
-
114
- request_preferred_locale(env) do |locale, path|
115
- # We have extracted a locale from the path, so from this point on we should use the updated path:
116
- env = env.merge(Rack::PATH_INFO => path.to_s)
117
-
118
- yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
119
- end
120
-
121
- browser_preferred_locales(env).each do |locale|
122
- yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
123
- end
124
-
125
- @default_locales.each do |locale|
126
- yield env.merge(CURRENT_LOCALE_KEY => locale) if locales.add? locale
127
- end
128
- end
129
-
130
- def host_preferred_locales(env)
131
- http_host = env[Rack::HTTP_HOST]
132
-
133
- # Yield all hosts which match the incoming http_host:
134
- @hosts.each do |pattern, locale|
135
- yield locale if http_host[pattern]
136
- end
137
- end
138
-
139
- def request_preferred_locale(env)
140
- path = Path[env[Rack::PATH_INFO]]
141
-
142
- if request_locale = @all_locales.patterns[path.first]
143
- # Remove the localization prefix:
144
- path.delete_at(0)
145
-
146
- yield request_locale, path
147
- end
148
- end
149
-
150
- def browser_preferred_locales(env)
151
- accept_languages = env[HTTP_ACCEPT_LANGUAGE]
152
-
153
- # No user prefered languages:
154
- return [] unless accept_languages
155
-
156
- # Extract the ordered list of languages:
157
- languages = HTTP::Accept::Languages.parse(accept_languages)
158
-
159
- # Returns available languages based on the order languages:
160
- return @all_locales & languages
161
- rescue HTTP::Accept::ParseError
162
- # If we fail to parse the browser Accept-Language header, we ignore it (silently).
163
- return []
164
- end
165
-
166
- def localized?(env)
167
- # Ignore requests which match the ignored paths:
168
- path_info = env[Rack::PATH_INFO]
169
- return false if @ignore.any? { |pattern| path_info[pattern] != nil }
170
-
171
- return true
172
- end
173
-
174
- # Set the Vary: header on the response to indicate that this response should include the header in the cache key.
175
- def vary(env, response)
176
- headers = response[1].to_a
177
-
178
- # This response was based on the Accept-Language header:
179
- headers << ["Vary", "Accept-Language"]
180
-
181
- # Althought this header is generally not supported, we supply it anyway as it is useful for debugging:
182
- if locale = env[CURRENT_LOCALE_KEY]
183
- # Set the Content-Location to point to the localized URI as requested:
184
- headers["Content-Location"] = "/#{locale}" + env[Rack::PATH_INFO]
185
- end
186
-
187
- return response
188
- end
189
-
190
- def call(env)
191
- # Pass the request through if it shouldn't be localized:
192
- return @app.call(env) unless localized?(env)
193
-
194
- env[LOCALIZATION_KEY] = self
195
-
196
- response = nil
197
-
198
- # We have a non-localized request, but there might be a localized resource. We return the best localization possible:
199
- preferred_locales(env) do |localized_env|
200
- # puts "Trying locale: #{localized_env[CURRENT_LOCALE_KEY]}: #{localized_env[Rack::PATH_INFO]}..."
201
-
202
- response = @app.call(localized_env)
203
-
204
- break unless response[0] >= 400
205
-
206
- response[2].close if response[2].respond_to?(:close)
207
- end
208
-
209
- return vary(env, response)
9
+ module Localization
10
+ def self.new(...)
11
+ Middleware.new(...)
210
12
  end
211
13
  end
212
14
  end
data/lib/utopia/path.rb CHANGED
@@ -57,9 +57,9 @@ module Utopia
57
57
 
58
58
  # Converts '+' into whitespace and hex encoded characters into their equivalent characters.
59
59
  def self.unescape(string)
60
- string.tr("+", " ").gsub(/((?:%[0-9a-fA-F]{2})+)/n) {
60
+ string.tr("+", " ").gsub(/((?:%[0-9a-fA-F]{2})+)/n) do
61
61
  [$1.delete("%")].pack("H*")
62
- }
62
+ end
63
63
  end
64
64
 
65
65
  def self.[] path
@@ -215,19 +215,34 @@ module Utopia
215
215
  end
216
216
 
217
217
  def simplify
218
- result = absolute? ? [""] : []
218
+ components = []
219
219
 
220
- @components.each do |bit|
221
- if bit == ".."
222
- result.pop
223
- elsif bit != "." && bit != ""
224
- result << bit
225
- end
220
+ index = 0
221
+
222
+ if @components[0] == ""
223
+ components << ""
224
+ index += 1
226
225
  end
227
226
 
228
- result << "" if directory?
227
+ while index < @components.size
228
+ bit = @components[index]
229
+ if bit == "."
230
+ # No-op (ignore current directory)
231
+ elsif bit == "" && index != @components.size - 1
232
+ # No-op (ignore multiple slashes)
233
+ elsif bit == ".." && components.last && components.last != ".."
234
+ if components.last != ""
235
+ # We can go up one level:
236
+ components.pop
237
+ end
238
+ else
239
+ components << bit
240
+ end
241
+
242
+ index += 1
243
+ end
229
244
 
230
- return self.class.new(result)
245
+ return self.class.new(components)
231
246
  end
232
247
 
233
248
  # Returns the first path component.
@@ -13,7 +13,7 @@ module Utopia
13
13
  def initialize(resource_path, resource_status, error_path, error_status)
14
14
  @resource_path = resource_path
15
15
  @resource_status = resource_status
16
-
16
+
17
17
  @error_path = error_path
18
18
  @error_status = error_status
19
19
 
@@ -47,7 +47,7 @@ module Utopia
47
47
  if unhandled_error?(response) && location = @codes[response[0]]
48
48
  error_request = env.merge(Rack::PATH_INFO => location, Rack::REQUEST_METHOD => Rack::GET)
49
49
  error_response = @app.call(error_request)
50
-
50
+
51
51
  if error_response[0] >= 400
52
52
  raise RequestFailure.new(env[Rack::PATH_INFO], response[0], location, error_response[0])
53
53
  else
@@ -4,7 +4,7 @@
4
4
  # Copyright, 2014-2022, by Samuel Williams.
5
5
 
6
6
  module Utopia
7
- class Session
7
+ module Session
8
8
  # A simple hash table which fetches it's values only when required.
9
9
  class LazyHash
10
10
  def initialize(&block)