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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/utopia/environment.rb +6 -6
- data/bake/utopia/server.rb +4 -1
- data/bake/utopia/site.rb +14 -14
- data/bake/utopia/static.rb +26 -13
- data/bake/utopia.rb +1 -1
- data/context/getting-started.md +7 -5
- data/context/index.yaml +1 -2
- data/context/middleware.md +42 -13
- data/lib/utopia/application.rb +83 -0
- data/lib/utopia/content/builder.rb +25 -0
- data/lib/utopia/content/document.rb +64 -6
- data/lib/utopia/content/link.rb +50 -5
- data/lib/utopia/content/links.rb +44 -7
- data/lib/utopia/content/markup.rb +74 -0
- data/lib/utopia/content/middleware.rb +48 -20
- data/lib/utopia/content/namespace.rb +10 -1
- data/lib/utopia/content/node.rb +66 -4
- data/lib/utopia/content/response.rb +12 -3
- data/lib/utopia/content.rb +3 -0
- data/lib/utopia/controller/actions.md +4 -4
- data/lib/utopia/controller/actions.rb +51 -3
- data/lib/utopia/controller/base.rb +72 -29
- data/lib/utopia/controller/middleware.rb +20 -15
- data/lib/utopia/controller/respond.rb +36 -32
- data/lib/utopia/controller/responder.rb +106 -45
- data/lib/utopia/controller/result.rb +11 -0
- data/lib/utopia/controller/rewrite.rb +39 -0
- data/lib/utopia/controller/variables.rb +15 -3
- data/lib/utopia/controller.rb +2 -0
- data/lib/utopia/exceptions/handler.rb +20 -11
- data/lib/utopia/exceptions/mailer.rb +56 -51
- data/lib/utopia/extensions/array_split.rb +6 -0
- data/lib/utopia/extensions/date_comparisons.rb +6 -0
- data/lib/utopia/http.rb +11 -48
- data/lib/utopia/import_map.rb +19 -11
- data/lib/utopia/localization/locales.rb +60 -0
- data/lib/utopia/localization/middleware.rb +107 -73
- data/lib/utopia/localization/preferences.rb +76 -0
- data/lib/utopia/localization/resolver.rb +47 -0
- data/lib/utopia/localization.rb +6 -0
- data/lib/utopia/middleware.rb +3 -4
- data/lib/utopia/path/matcher.rb +15 -0
- data/lib/utopia/path.rb +146 -10
- data/lib/utopia/redirection/client_redirect.rb +87 -0
- data/lib/utopia/redirection/directory_index.rb +41 -0
- data/lib/utopia/redirection/errors.rb +78 -0
- data/lib/utopia/redirection/moved.rb +52 -0
- data/lib/utopia/redirection/request_failure.rb +26 -0
- data/lib/utopia/redirection/rewrite.rb +42 -0
- data/lib/utopia/redirection.rb +7 -165
- data/lib/utopia/request.rb +202 -0
- data/lib/utopia/response.rb +73 -0
- data/lib/utopia/session/lazy_hash.rb +27 -1
- data/lib/utopia/session/middleware.rb +103 -30
- data/lib/utopia/session/serialization.rb +8 -0
- data/lib/utopia/session.rb +5 -0
- data/lib/utopia/setup.rb +23 -3
- data/lib/utopia/shell.rb +28 -7
- data/lib/utopia/static/local_file.rb +84 -61
- data/lib/utopia/static/middleware.rb +65 -33
- data/lib/utopia/static/mime_types.rb +38 -29
- data/lib/utopia/static.rb +2 -0
- data/lib/utopia/version.rb +3 -2
- data/lib/utopia.rb +1 -1
- data/license.md +1 -1
- data/readme.md +33 -4
- data/releases.md +15 -0
- data/setup/site/bake.rb +1 -1
- data/setup/site/config/application.rb +51 -0
- data/setup/site/config/serve.rb +8 -0
- data/setup/site/falcon.rb +17 -4
- data/setup/site/fixtures/website.rb +27 -11
- data/setup/site/gems.rb +1 -3
- data/setup/site/lib/readme.txt +1 -1
- data/setup/site/pages/welcome/index.xnode +3 -3
- data/setup/site/readme.md +0 -3
- data/setup/site/test/website.rb +2 -2
- data.tar.gz.sig +0 -0
- metadata +60 -34
- metadata.gz.sig +0 -0
- data/lib/utopia/localization/wrapper.rb +0 -52
- data/setup/site/Guardfile +0 -12
- data/setup/site/config.ru +0 -49
data/lib/utopia/path.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
4
|
# Copyright, 2009-2025, by Samuel Williams.
|
|
5
5
|
|
|
6
|
+
require "protocol/url/path"
|
|
7
|
+
|
|
6
8
|
module Utopia
|
|
7
9
|
# Represents a path as an array of path components. Useful for efficient URL manipulation.
|
|
8
10
|
class Path
|
|
@@ -10,12 +12,16 @@ module Utopia
|
|
|
10
12
|
|
|
11
13
|
SEPARATOR = "/"
|
|
12
14
|
|
|
15
|
+
# Initialize a path from its individual components.
|
|
16
|
+
# @parameter components [Array(String)] The path components, including empty components that denote leading or trailing separators.
|
|
13
17
|
def initialize(components = [])
|
|
14
18
|
@components = components
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
attr_accessor :components
|
|
18
22
|
|
|
23
|
+
# Freeze this object and its internal state.
|
|
24
|
+
# @returns [self] This object.
|
|
19
25
|
def freeze
|
|
20
26
|
return self if frozen?
|
|
21
27
|
|
|
@@ -24,20 +30,30 @@ module Utopia
|
|
|
24
30
|
super
|
|
25
31
|
end
|
|
26
32
|
|
|
33
|
+
# Check whether this path has no components.
|
|
34
|
+
# @returns [Boolean] Whether the path has no components.
|
|
27
35
|
def empty?
|
|
28
36
|
@components.empty?
|
|
29
37
|
end
|
|
30
38
|
|
|
39
|
+
# Construct the root path.
|
|
40
|
+
# @returns [Path] The root path.
|
|
31
41
|
def self.root
|
|
32
42
|
self.new([""])
|
|
33
43
|
end
|
|
34
44
|
|
|
35
|
-
#
|
|
45
|
+
# Compute the number of leading components shared by two sequences.
|
|
46
|
+
# @parameter a [Array] The first sequence.
|
|
47
|
+
# @parameter b [Array] The second sequence.
|
|
48
|
+
# @returns [Integer | Nil] The shared prefix length, or `nil` when every component in the shorter sequence matches.
|
|
36
49
|
def self.prefix_length(a, b)
|
|
37
50
|
[a.size, b.size].min.times{|i| return i if a[i] != b[i]}
|
|
38
51
|
end
|
|
39
52
|
|
|
40
|
-
#
|
|
53
|
+
# Compute the shortest relative path from the containing directory of `root` to `path`.
|
|
54
|
+
# @parameter path [Path | String | Array] The destination path.
|
|
55
|
+
# @parameter root [Path | String | Array] The source path.
|
|
56
|
+
# @returns [Path] The shortest relative path.
|
|
41
57
|
def self.shortest_path(path, root)
|
|
42
58
|
path = self.create(path)
|
|
43
59
|
root = self.create(root).dirname
|
|
@@ -51,21 +67,32 @@ module Utopia
|
|
|
51
67
|
return self.create([".."] * up + path.components[i..-1])
|
|
52
68
|
end
|
|
53
69
|
|
|
70
|
+
# Compute the shortest relative path from the containing directory of `root` to this path.
|
|
71
|
+
# @parameter root [Path | String | Array] The source path.
|
|
72
|
+
# @returns [Path] The shortest relative path.
|
|
54
73
|
def shortest_path(root)
|
|
55
74
|
self.class.shortest_path(self, root)
|
|
56
75
|
end
|
|
57
76
|
|
|
58
|
-
#
|
|
77
|
+
# Decode URL-encoded path content, converting `+` to whitespace and percent-encoded bytes to their corresponding characters.
|
|
78
|
+
# @parameter string [String] The encoded content.
|
|
79
|
+
# @returns [String] The decoded content.
|
|
59
80
|
def self.unescape(string)
|
|
60
81
|
string.tr("+", " ").gsub(/((?:%[0-9a-fA-F]{2})+)/n) do
|
|
61
82
|
[$1.delete("%")].pack("H*")
|
|
62
83
|
end
|
|
63
84
|
end
|
|
64
85
|
|
|
86
|
+
# Coerce the given value into a path.
|
|
87
|
+
# @parameter path [Utopia::Path | String] The path.
|
|
88
|
+
# @returns [Path | Nil] The coerced path.
|
|
65
89
|
def self.[] path
|
|
66
90
|
self.create(path)
|
|
67
91
|
end
|
|
68
92
|
|
|
93
|
+
# Convert a path value into an array of components.
|
|
94
|
+
# @parameter path [Utopia::Path | String] The path.
|
|
95
|
+
# @returns [Array] The path components.
|
|
69
96
|
def self.split(path)
|
|
70
97
|
case path
|
|
71
98
|
when Path
|
|
@@ -79,19 +106,30 @@ module Utopia
|
|
|
79
106
|
end
|
|
80
107
|
end
|
|
81
108
|
|
|
82
|
-
#
|
|
109
|
+
# Construct a path from URL-encoded text. This is an optimized direct entry point used by controller invocations.
|
|
110
|
+
# @parameter string [String] The encoded path.
|
|
111
|
+
# @returns [Path] The decoded path.
|
|
83
112
|
def self.from_string(string)
|
|
84
113
|
self.new(unescape(string).split(SEPARATOR, -1))
|
|
85
114
|
end
|
|
86
115
|
|
|
116
|
+
# Load a path from its serialized form.
|
|
117
|
+
# @parameter value [String | Nil] The serialized path.
|
|
118
|
+
# @returns [Path | Nil] The loaded path.
|
|
87
119
|
def self.load(value)
|
|
88
120
|
from_string(value) if value
|
|
89
121
|
end
|
|
90
122
|
|
|
123
|
+
# Serialize a path.
|
|
124
|
+
# @parameter instance [Path | Nil] The path to serialize.
|
|
125
|
+
# @returns [String | Nil] The serialized path.
|
|
91
126
|
def self.dump(instance)
|
|
92
127
|
instance.to_s if instance
|
|
93
128
|
end
|
|
94
129
|
|
|
130
|
+
# Coerce a value into a path.
|
|
131
|
+
# @parameter path [Path | Array | String | Object | Nil] The value to coerce.
|
|
132
|
+
# @returns [Path | Nil] The coerced path.
|
|
95
133
|
def self.create(path)
|
|
96
134
|
case path
|
|
97
135
|
when Path
|
|
@@ -107,22 +145,34 @@ module Utopia
|
|
|
107
145
|
end
|
|
108
146
|
end
|
|
109
147
|
|
|
148
|
+
# Replace this path's components with a copy of another path's components.
|
|
149
|
+
# @parameter other_path [Path] The replacement path.
|
|
150
|
+
# @returns [Array(String)] The copied components.
|
|
110
151
|
def replace(other_path)
|
|
111
152
|
@components = other_path.components.dup
|
|
112
153
|
end
|
|
113
154
|
|
|
155
|
+
# Check whether this collection includes the given value.
|
|
156
|
+
# @parameter arguments [Array] The arguments.
|
|
157
|
+
# @returns [Boolean] Whether any component matches the given argument.
|
|
114
158
|
def include?(*arguments)
|
|
115
159
|
@components.include?(*arguments)
|
|
116
160
|
end
|
|
117
161
|
|
|
162
|
+
# Check whether this path denotes a directory.
|
|
163
|
+
# @returns [Boolean] Whether the path ends with a directory separator.
|
|
118
164
|
def directory?
|
|
119
165
|
return @components.last == ""
|
|
120
166
|
end
|
|
121
167
|
|
|
168
|
+
# Check whether this path denotes a file.
|
|
169
|
+
# @returns [Boolean] Whether the path ends with a file component.
|
|
122
170
|
def file?
|
|
123
171
|
return @components.last != ""
|
|
124
172
|
end
|
|
125
173
|
|
|
174
|
+
# Convert this path to a directory path.
|
|
175
|
+
# @returns [Path] A directory path.
|
|
126
176
|
def to_directory
|
|
127
177
|
if directory?
|
|
128
178
|
return self
|
|
@@ -131,14 +181,20 @@ module Utopia
|
|
|
131
181
|
end
|
|
132
182
|
end
|
|
133
183
|
|
|
184
|
+
# Check whether this path is relative.
|
|
185
|
+
# @returns [Boolean] Whether the path is relative.
|
|
134
186
|
def relative?
|
|
135
187
|
@components.first != ""
|
|
136
188
|
end
|
|
137
189
|
|
|
190
|
+
# Check whether this path is absolute.
|
|
191
|
+
# @returns [Boolean] Whether the path is absolute.
|
|
138
192
|
def absolute?
|
|
139
193
|
@components.first == ""
|
|
140
194
|
end
|
|
141
195
|
|
|
196
|
+
# Convert this path to an absolute path.
|
|
197
|
+
# @returns [Path] An absolute path.
|
|
142
198
|
def to_absolute
|
|
143
199
|
if absolute?
|
|
144
200
|
return self
|
|
@@ -147,10 +203,14 @@ module Utopia
|
|
|
147
203
|
end
|
|
148
204
|
end
|
|
149
205
|
|
|
206
|
+
# Remove the first component when this path is relative.
|
|
207
|
+
# @returns [String | Nil] The removed component, or `nil` when the path is absolute.
|
|
150
208
|
def to_relative!
|
|
151
209
|
@components.shift if relative?
|
|
152
210
|
end
|
|
153
211
|
|
|
212
|
+
# Convert this object to a string.
|
|
213
|
+
# @returns [String] The resulting string.
|
|
154
214
|
def to_str
|
|
155
215
|
if @components == [""]
|
|
156
216
|
SEPARATOR
|
|
@@ -161,11 +221,28 @@ module Utopia
|
|
|
161
221
|
|
|
162
222
|
alias to_s to_str
|
|
163
223
|
|
|
224
|
+
# Encode this application path as a URL path.
|
|
225
|
+
# @returns [Protocol::URL::Path] The encoded URL path.
|
|
226
|
+
def to_url_path
|
|
227
|
+
# Preserve Utopia's compact representation of the absolute root:
|
|
228
|
+
if @components == [""]
|
|
229
|
+
return Protocol::URL::Path[SEPARATOR]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
return Protocol::URL::Path.for(
|
|
233
|
+
@components,
|
|
234
|
+
encoding: Protocol::URL::Encoding::System,
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Convert this path to an array of components.
|
|
239
|
+
# @returns [Array] The resulting values.
|
|
164
240
|
def to_a
|
|
165
241
|
@components
|
|
166
242
|
end
|
|
167
243
|
|
|
168
244
|
# @parameter other [Array(String)] The path components to append.
|
|
245
|
+
# @returns [Path] The joined and simplified path.
|
|
169
246
|
def join(other)
|
|
170
247
|
# Check whether other is an absolute path:
|
|
171
248
|
if other.first == ""
|
|
@@ -175,10 +252,16 @@ module Utopia
|
|
|
175
252
|
end
|
|
176
253
|
end
|
|
177
254
|
|
|
255
|
+
# Resolve this path relative to a root path.
|
|
256
|
+
# @parameter root [Path] The root path.
|
|
257
|
+
# @returns [Path] The resolved path.
|
|
178
258
|
def expand(root)
|
|
179
259
|
root + self
|
|
180
260
|
end
|
|
181
261
|
|
|
262
|
+
# Append path components and return the resulting path.
|
|
263
|
+
# @parameter other [Path | Array | String | Object] The value to append.
|
|
264
|
+
# @returns [Path] The joined and simplified path, or `other` when it is an absolute path.
|
|
182
265
|
def +(other)
|
|
183
266
|
if other.kind_of? Path
|
|
184
267
|
if other.absolute?
|
|
@@ -195,6 +278,9 @@ module Utopia
|
|
|
195
278
|
end
|
|
196
279
|
end
|
|
197
280
|
|
|
281
|
+
# Prepend a path to this path.
|
|
282
|
+
# @parameter arguments [Array] The arguments accepted by {.create}.
|
|
283
|
+
# @returns [Path] The prefixed path.
|
|
198
284
|
def with_prefix(*arguments)
|
|
199
285
|
self.class.create(*arguments) + self
|
|
200
286
|
end
|
|
@@ -202,6 +288,8 @@ module Utopia
|
|
|
202
288
|
# Computes the difference of the path.
|
|
203
289
|
# /a/b/c - /a/b -> c
|
|
204
290
|
# a/b/c - a/b -> c
|
|
291
|
+
# @parameter other [Path] The prefix path to remove.
|
|
292
|
+
# @returns [Path] The remaining path.
|
|
205
293
|
def -(other)
|
|
206
294
|
i = 0
|
|
207
295
|
|
|
@@ -214,6 +302,8 @@ module Utopia
|
|
|
214
302
|
return self.class.new(@components[i,@components.size])
|
|
215
303
|
end
|
|
216
304
|
|
|
305
|
+
# Normalize current-directory, parent-directory, and repeated-separator components.
|
|
306
|
+
# @returns [Path] The normalized path.
|
|
217
307
|
def simplify
|
|
218
308
|
components = []
|
|
219
309
|
|
|
@@ -245,7 +335,8 @@ module Utopia
|
|
|
245
335
|
return self.class.new(components)
|
|
246
336
|
end
|
|
247
337
|
|
|
248
|
-
#
|
|
338
|
+
# Return the first path component, excluding the root marker.
|
|
339
|
+
# @returns [String | Nil] The first component.
|
|
249
340
|
def first
|
|
250
341
|
if absolute?
|
|
251
342
|
@components[1]
|
|
@@ -254,7 +345,8 @@ module Utopia
|
|
|
254
345
|
end
|
|
255
346
|
end
|
|
256
347
|
|
|
257
|
-
#
|
|
348
|
+
# Return the last path component, excluding the root marker.
|
|
349
|
+
# @returns [String | Nil] The last component.
|
|
258
350
|
def last
|
|
259
351
|
if @components != [""]
|
|
260
352
|
@components.last
|
|
@@ -263,7 +355,8 @@ module Utopia
|
|
|
263
355
|
|
|
264
356
|
alias last? file?
|
|
265
357
|
|
|
266
|
-
#
|
|
358
|
+
# Remove the last path component without converting the root path to a relative path.
|
|
359
|
+
# @returns [String | Nil] The removed component.
|
|
267
360
|
def pop
|
|
268
361
|
# We don't want to convert an absolute path to a relative path.
|
|
269
362
|
if @components != [""]
|
|
@@ -271,30 +364,39 @@ module Utopia
|
|
|
271
364
|
end
|
|
272
365
|
end
|
|
273
366
|
|
|
274
|
-
# @
|
|
367
|
+
# @returns [String] The last path component without its file extension.
|
|
275
368
|
def basename
|
|
276
369
|
basename, _ = @components.last.split(".", 2)
|
|
277
370
|
|
|
278
371
|
return basename || ""
|
|
279
372
|
end
|
|
280
373
|
|
|
281
|
-
# @
|
|
374
|
+
# @returns [String | Nil] The last path component's file extension.
|
|
282
375
|
def extension
|
|
283
376
|
_, extension = @components.last.split(".", 2)
|
|
284
377
|
|
|
285
378
|
return extension
|
|
286
379
|
end
|
|
287
380
|
|
|
381
|
+
# Remove trailing path components.
|
|
382
|
+
# @parameter count [Integer] The number of components.
|
|
383
|
+
# @returns [Path] The containing path.
|
|
288
384
|
def dirname(count = 1)
|
|
289
385
|
path = self.class.new(@components[0...-count])
|
|
290
386
|
|
|
291
387
|
return absolute? ? path.to_absolute : path
|
|
292
388
|
end
|
|
293
389
|
|
|
390
|
+
# Format this path using a local filesystem separator.
|
|
391
|
+
# @parameter separator [String] The component separator.
|
|
392
|
+
# @returns [String] The local path.
|
|
294
393
|
def local_path(separator = File::SEPARATOR)
|
|
295
394
|
@components.join(separator)
|
|
296
395
|
end
|
|
297
396
|
|
|
397
|
+
# Enumerate paths from the first component down to this path.
|
|
398
|
+
# @yields {|path| ...} Each successively longer path.
|
|
399
|
+
# @returns [Enumerator | Array] An enumerator when no block is given, otherwise the component array.
|
|
298
400
|
def descend(&block)
|
|
299
401
|
return to_enum(:descend) unless block_given?
|
|
300
402
|
|
|
@@ -307,6 +409,9 @@ module Utopia
|
|
|
307
409
|
end
|
|
308
410
|
end
|
|
309
411
|
|
|
412
|
+
# Enumerate paths from this path up to its first component.
|
|
413
|
+
# @yields {|path| ...} Each successively shorter path.
|
|
414
|
+
# @returns [Enumerator | Nil] An enumerator when no block is given.
|
|
310
415
|
def ascend(&block)
|
|
311
416
|
return to_enum(:ascend) unless block_given?
|
|
312
417
|
|
|
@@ -319,6 +424,9 @@ module Utopia
|
|
|
319
424
|
end
|
|
320
425
|
end
|
|
321
426
|
|
|
427
|
+
# Split this path around a component or component index.
|
|
428
|
+
# @parameter at [Integer | String] The component index or value at which to split.
|
|
429
|
+
# @returns [Array(Path, Path) | Nil] The paths before and after the matched component, or `nil` when it is not found.
|
|
322
430
|
def split(at)
|
|
323
431
|
if at.kind_of?(String)
|
|
324
432
|
at = @components.index(at)
|
|
@@ -331,22 +439,35 @@ module Utopia
|
|
|
331
439
|
end
|
|
332
440
|
end
|
|
333
441
|
|
|
442
|
+
# Copy this path and its component array.
|
|
443
|
+
# @returns [Path] The copied path.
|
|
334
444
|
def dup
|
|
335
445
|
return Path.new(components.dup)
|
|
336
446
|
end
|
|
337
447
|
|
|
448
|
+
# Compare this object with another object.
|
|
449
|
+
# @parameter other [Object] The object to compare.
|
|
450
|
+
# @returns [Integer | Nil] The comparison result.
|
|
338
451
|
def <=> other
|
|
339
452
|
@components <=> other.components
|
|
340
453
|
end
|
|
341
454
|
|
|
455
|
+
# Check whether this object is equivalent to another object.
|
|
456
|
+
# @parameter other [Object] The object to compare.
|
|
457
|
+
# @returns [Boolean] Whether the paths have the same class and components.
|
|
342
458
|
def eql? other
|
|
343
459
|
self.class.eql?(other.class) and @components.eql?(other.components)
|
|
344
460
|
end
|
|
345
461
|
|
|
462
|
+
# Compute the hash value for this object.
|
|
463
|
+
# @returns [Integer] The resulting integer.
|
|
346
464
|
def hash
|
|
347
465
|
@components.hash
|
|
348
466
|
end
|
|
349
467
|
|
|
468
|
+
# Compare this object with another object.
|
|
469
|
+
# @parameter other [Object] The object to compare.
|
|
470
|
+
# @returns [Boolean] Whether the path is equivalent to the given string, array, or path.
|
|
350
471
|
def == other
|
|
351
472
|
return false unless other
|
|
352
473
|
|
|
@@ -357,6 +478,9 @@ module Utopia
|
|
|
357
478
|
end
|
|
358
479
|
end
|
|
359
480
|
|
|
481
|
+
# Check whether this path starts with the given path.
|
|
482
|
+
# @parameter other [Path] The possible prefix.
|
|
483
|
+
# @returns [Boolean] Whether this path starts with all components of `other`.
|
|
360
484
|
def start_with? other
|
|
361
485
|
other.components.each_with_index do |part, index|
|
|
362
486
|
return false if @components[index] != part
|
|
@@ -365,15 +489,24 @@ module Utopia
|
|
|
365
489
|
return true
|
|
366
490
|
end
|
|
367
491
|
|
|
492
|
+
# Fetch one or more path components, excluding root and directory markers from indexing.
|
|
493
|
+
# @parameter index [Integer | Range] The component index or range.
|
|
494
|
+
# @returns [String | Array(String) | Nil] The selected component or components.
|
|
368
495
|
def [] index
|
|
369
496
|
return @components[component_offset(index)]
|
|
370
497
|
end
|
|
371
498
|
|
|
372
|
-
#
|
|
499
|
+
# Replace one or more path components using the same root- and directory-marker-aware indexing as {#[]}.
|
|
500
|
+
# @parameter index [Integer | Range] The component index or range.
|
|
501
|
+
# @parameter value [String | Array(String)] The replacement component or components.
|
|
502
|
+
# @returns [String | Array(String)] The assigned value.
|
|
373
503
|
def []= index, value
|
|
374
504
|
return @components[component_offset(index)] = value
|
|
375
505
|
end
|
|
376
506
|
|
|
507
|
+
# Delete a path component, excluding root and directory markers from indexing.
|
|
508
|
+
# @parameter index [Integer] The component index.
|
|
509
|
+
# @returns [String | Nil] The deleted component.
|
|
377
510
|
def delete_at(index)
|
|
378
511
|
@components.delete_at(component_offset(index))
|
|
379
512
|
end
|
|
@@ -400,6 +533,9 @@ module Utopia
|
|
|
400
533
|
end
|
|
401
534
|
end
|
|
402
535
|
|
|
536
|
+
# Coerce a value into a {Path}.
|
|
537
|
+
# @parameter path [Utopia::Path | String] The path.
|
|
538
|
+
# @returns [Path | Nil] The coerced path.
|
|
403
539
|
def self.Path(path)
|
|
404
540
|
Path.create(path)
|
|
405
541
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "../middleware"
|
|
7
|
+
require_relative "../response"
|
|
8
|
+
|
|
9
|
+
module Utopia
|
|
10
|
+
# A middleware which assists with redirecting from one path to another.
|
|
11
|
+
module Redirection
|
|
12
|
+
# We cache 301 redirects for 24 hours.
|
|
13
|
+
MAX_AGE = 3600*24
|
|
14
|
+
|
|
15
|
+
# A basic client-side redirect.
|
|
16
|
+
class ClientRedirect < Protocol::HTTP::Middleware
|
|
17
|
+
# Initialize client-side redirection behavior.
|
|
18
|
+
# @parameter app [Interface(:call)] The downstream application.
|
|
19
|
+
# @parameter status [Integer] The status.
|
|
20
|
+
# @parameter max_age [Integer] The maximum cache age in seconds.
|
|
21
|
+
def initialize(app, status: 307, max_age: MAX_AGE)
|
|
22
|
+
super(app)
|
|
23
|
+
|
|
24
|
+
@status = status
|
|
25
|
+
@max_age = max_age
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Freeze this object and its internal state.
|
|
29
|
+
# @returns [self] This object.
|
|
30
|
+
def freeze
|
|
31
|
+
return self if frozen?
|
|
32
|
+
|
|
33
|
+
@status.freeze
|
|
34
|
+
@max_age.freeze
|
|
35
|
+
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr :status
|
|
40
|
+
attr :max_age
|
|
41
|
+
|
|
42
|
+
# Build the cache control header value.
|
|
43
|
+
# @returns [String] The cache-control value.
|
|
44
|
+
def cache_control
|
|
45
|
+
# http://jacquesmattheij.com/301-redirects-a-dangerous-one-way-street
|
|
46
|
+
"max-age=#{self.max_age}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build headers for a client redirect.
|
|
50
|
+
# @parameter location [String] The redirect location.
|
|
51
|
+
# @returns [Hash(String, String)] The redirect headers.
|
|
52
|
+
def make_headers(location)
|
|
53
|
+
{
|
|
54
|
+
HTTP::LOCATION => location,
|
|
55
|
+
HTTP::CACHE_CONTROL => self.cache_control
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build a redirect response for the given location.
|
|
60
|
+
# @parameter location [String] The redirect location.
|
|
61
|
+
# @returns [Protocol::HTTP::Response] The redirect response.
|
|
62
|
+
def redirect(location)
|
|
63
|
+
return Response[self.status, self.make_headers(location), []]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Resolve a normalized request path to a redirect response.
|
|
67
|
+
# @parameter path [String] The normalized request path.
|
|
68
|
+
# @returns [Protocol::HTTP::Response | false] The redirect response, or `false` by default.
|
|
69
|
+
def [] path
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Redirect a normalized request path when it matches, otherwise invoke the application.
|
|
74
|
+
# @parameter request [Utopia::Request] The request.
|
|
75
|
+
# @returns [Protocol::HTTP::Response] The redirect or downstream response.
|
|
76
|
+
def call(request)
|
|
77
|
+
path = request.url.path.encoded
|
|
78
|
+
|
|
79
|
+
if redirection = self[path]
|
|
80
|
+
return redirection
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return @delegate.call(request)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
# Redirect urls that end with a `/`, e.g. directories.
|
|
11
|
+
class DirectoryIndex < ClientRedirect
|
|
12
|
+
# Initialize directory-index redirection.
|
|
13
|
+
# @parameter app [Interface(:call)] The downstream application.
|
|
14
|
+
# @parameter index [Integer] The index.
|
|
15
|
+
def initialize(app, index: "index")
|
|
16
|
+
@index = index
|
|
17
|
+
|
|
18
|
+
super(app)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Freeze this object and its internal state.
|
|
22
|
+
# @returns [self] This object.
|
|
23
|
+
def freeze
|
|
24
|
+
return self if frozen?
|
|
25
|
+
|
|
26
|
+
@index.freeze
|
|
27
|
+
|
|
28
|
+
return super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Redirect a directory path to its index path.
|
|
32
|
+
# @parameter path [String] The normalized request path.
|
|
33
|
+
# @returns [Protocol::HTTP::Response | Nil] The redirect response when the path ends with `/`.
|
|
34
|
+
def [] path
|
|
35
|
+
if path.end_with?("/")
|
|
36
|
+
return redirect(path + @index)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "../middleware"
|
|
7
|
+
require_relative "../request"
|
|
8
|
+
require_relative "../response"
|
|
9
|
+
require_relative "request_failure"
|
|
10
|
+
|
|
11
|
+
module Utopia
|
|
12
|
+
module Redirection
|
|
13
|
+
# A middleware which performs internal redirects based on error status codes.
|
|
14
|
+
#
|
|
15
|
+
# Place this middleware after client-visible redirection middleware in the
|
|
16
|
+
# application configuration. Internal error-document requests invoke the
|
|
17
|
+
# delegate directly, bypassing middleware configured before this one.
|
|
18
|
+
class Errors < Protocol::HTTP::Middleware
|
|
19
|
+
# @param codes [Hash<Integer,String>] The redirection path for a given error code.
|
|
20
|
+
def initialize(app, codes = {})
|
|
21
|
+
super(app)
|
|
22
|
+
|
|
23
|
+
@codes = codes
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Freeze this object and its internal state.
|
|
27
|
+
# @returns [self] This object.
|
|
28
|
+
def freeze
|
|
29
|
+
return self if frozen?
|
|
30
|
+
|
|
31
|
+
@codes.freeze
|
|
32
|
+
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check whether the response status requires error handling.
|
|
37
|
+
# @parameter response [Protocol::HTTP::Response] The response.
|
|
38
|
+
# @returns [Boolean] Whether the response is an error without handler-provided headers.
|
|
39
|
+
def unhandled_error?(response)
|
|
40
|
+
response.status >= 400 && response.headers.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Replace an unhandled error response with its configured error document.
|
|
44
|
+
# @parameter request [Utopia::Request] The request.
|
|
45
|
+
# @returns [Protocol::HTTP::Response] The original or error-document response.
|
|
46
|
+
# @raises [RequestFailure] If the configured error document also fails.
|
|
47
|
+
def call(request)
|
|
48
|
+
response = Response.wrap(@delegate.call(request))
|
|
49
|
+
|
|
50
|
+
if unhandled_error?(response) && location = @codes[response.status]
|
|
51
|
+
resource_status = response.status
|
|
52
|
+
|
|
53
|
+
# The original response is replaced by the configured error document:
|
|
54
|
+
response.close
|
|
55
|
+
|
|
56
|
+
error_request = request.with(method: "GET", path: location)
|
|
57
|
+
|
|
58
|
+
error_response = Response.wrap(@delegate.call(error_request))
|
|
59
|
+
|
|
60
|
+
if error_response.status >= 400
|
|
61
|
+
error = RequestFailure.new(request.url.path.encoded, resource_status, location, error_response.status)
|
|
62
|
+
|
|
63
|
+
# The failed error document will not be returned to the server:
|
|
64
|
+
error_response.close(error)
|
|
65
|
+
|
|
66
|
+
raise error
|
|
67
|
+
else
|
|
68
|
+
# Feed the error code back with the error document:
|
|
69
|
+
error_response.status = resource_status
|
|
70
|
+
return error_response
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
return response
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
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 new prefix.
|
|
11
|
+
class Moved < ClientRedirect
|
|
12
|
+
# Initialize prefix redirection behavior.
|
|
13
|
+
# @parameter app [Interface(:call)] The downstream application.
|
|
14
|
+
# @parameter pattern [Regexp] The path pattern.
|
|
15
|
+
# @parameter prefix [String] The prefix.
|
|
16
|
+
# @parameter status [Integer] The status.
|
|
17
|
+
# @parameter flatten [bool] Whether to flatten the rewritten path.
|
|
18
|
+
def initialize(app, pattern, prefix, status: 301, flatten: false)
|
|
19
|
+
@pattern = pattern
|
|
20
|
+
@prefix = prefix
|
|
21
|
+
@flatten = flatten
|
|
22
|
+
|
|
23
|
+
super(app, status: status)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Freeze this object and its internal state.
|
|
27
|
+
# @returns [self] This object.
|
|
28
|
+
def freeze
|
|
29
|
+
return self if frozen?
|
|
30
|
+
|
|
31
|
+
@pattern.freeze
|
|
32
|
+
@prefix.freeze
|
|
33
|
+
@flatten.freeze
|
|
34
|
+
|
|
35
|
+
return super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Redirect a matching path to the configured prefix.
|
|
39
|
+
# @parameter path [String] The normalized request path.
|
|
40
|
+
# @returns [Protocol::HTTP::Response | Nil] The redirect response when the pattern matches.
|
|
41
|
+
def [] path
|
|
42
|
+
if path.start_with?(@pattern)
|
|
43
|
+
if @flatten
|
|
44
|
+
return redirect(@prefix)
|
|
45
|
+
else
|
|
46
|
+
return redirect(path.sub(@pattern, @prefix))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|