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
@@ -3,194 +3,12 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2009-2025, by Samuel Williams.
5
5
 
6
- require_relative "middleware"
7
- require_relative "localization"
8
-
9
- require_relative "content/links"
10
- require_relative "content/node"
11
- require_relative "content/markup"
12
- require_relative "content/tags"
13
-
14
- require "xrb/template"
15
-
16
- require "concurrent/map"
17
-
18
- require "traces/provider"
6
+ require_relative "content/middleware"
19
7
 
20
8
  module Utopia
21
- # A middleware which serves dynamically generated content based on markup files.
22
- class Content
23
- CONTENT_NAMESPACE = "content".freeze
24
- UTOPIA_NAMESPACE = "utopia".freeze
25
- DEFERRED_TAG_NAME = "utopia:deferred".freeze
26
- CONTENT_TAG_NAME = "utopia:content".freeze
27
-
28
- # @param root [String] The content root where pages will be generated from.
29
- # @param namespaces [Hash<String,Library>] Tag namespaces for dynamic tag lookup.
30
- def initialize(app, root: Utopia::default_root, namespaces: {})
31
- @app = app
32
- @root = root
33
-
34
- @template_cache = Concurrent::Map.new
35
- @node_cache = Concurrent::Map.new
36
-
37
- @links = Links.new(@root)
38
-
39
- @namespaces = namespaces
40
-
41
- # Default content namespace for dynamic path based lookup:
42
- @namespaces[CONTENT_NAMESPACE] ||= self.method(:content_tag)
43
-
44
- # The core namespace for utopia specific functionality:
45
- @namespaces[UTOPIA_NAMESPACE] ||= Tags
46
- end
47
-
48
- def freeze
49
- return self if frozen?
50
-
51
- @root.freeze
52
- @namespaces.values.each(&:freeze)
53
- @namespaces.freeze
54
-
55
- super
56
- end
57
-
58
- attr :root
59
-
60
- # TODO we should remove this method and expose `@links` directly.
61
- def links(path, **options)
62
- @links.index(path, **options)
63
- end
64
-
65
- def fetch_template(path)
66
- @template_cache.fetch_or_store(path.to_s) do
67
- XRB::Template.load_file(path)
68
- end
69
- end
70
-
71
- # Look up a named tag such as `<entry />` or `<content:page>...`
72
- def lookup_tag(qualified_name, node)
73
- namespace, name = XRB::Tag.split(qualified_name)
74
-
75
- if library = @namespaces[namespace]
76
- library.call(name, node)
77
- end
78
- end
79
-
80
- # @param path [Path] the request path is an absolute uri path, e.g. `/foo/bar`. If an xnode file exists on disk for this exact path, it is instantiated, otherwise nil.
81
- def lookup_node(path, locale = nil)
82
- resolve_link(
83
- @links.for(path, locale)
84
- )
85
- end
86
-
87
- def resolve_link(link)
88
- if full_path = link&.full_path(@root)
89
- if File.exist?(full_path)
90
- return Node.new(self, link.path, link.path, full_path)
91
- end
92
- end
93
- end
94
-
95
- def respond(link, request)
96
- if node = resolve_link(link)
97
- attributes = request.env.fetch(VARIABLES_KEY, {}).to_hash
98
-
99
- return node.process!(request, attributes)
100
- elsif redirect_uri = link[:uri]
101
- return [307, {HTTP::LOCATION => redirect_uri}, []]
102
- end
103
- end
104
-
105
- def call(env)
106
- request = Rack::Request.new(env)
107
- path = Path.create(request.path_info)
108
-
109
- # Check if the request is to a non-specific index. This only works for requests with a given name:
110
- basename = path.basename
111
- directory_path = File.join(@root, path.dirname.components, basename)
112
-
113
- # If the request for /foo/bar is actually a directory, rewrite it to /foo/bar/index:
114
- if File.directory? directory_path
115
- index_path = [basename, INDEX]
116
-
117
- return [307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []]
118
- end
119
-
120
- locale = env[Localization::CURRENT_LOCALE_KEY]
121
- if link = @links.for(path, locale)
122
- if response = self.respond(link, request)
123
- return response
124
- end
125
- end
126
-
127
- return @app.call(env)
128
- end
129
-
130
- private
131
-
132
- def lookup_content(name, parent_path)
133
- if String === name && name.index("/")
134
- name = Path.create(name)
135
- end
136
-
137
- if Path === name
138
- name = parent_path + name
139
- name_path = name.components.dup
140
- name_path[-1] += XNODE_EXTENSION
141
- else
142
- name_path = name + XNODE_EXTENSION
143
- end
144
-
145
- components = parent_path.components.dup
146
-
147
- while components.any?
148
- tag_path = File.join(@root, components, name_path)
149
-
150
- if File.exist? tag_path
151
- return Node.new(self, Path[components] + name, parent_path + name, tag_path)
152
- end
153
-
154
- if String === name_path
155
- tag_path = File.join(@root, components, "_" + name_path)
156
-
157
- if File.exist? tag_path
158
- return Node.new(self, Path[components] + name, parent_path + name, tag_path)
159
- end
160
- end
161
-
162
- components.pop
163
- end
164
-
165
- return nil
166
- end
167
-
168
- def content_tag(name, node)
169
- full_path = node.parent_path + name
170
-
171
- name = full_path.pop
172
-
173
- # If the current node is called 'foo', we can't lookup 'foo' in the current directory or we will have infinite recursion.
174
- while full_path.last == name
175
- full_path.pop
176
- end
177
-
178
- cache_key = full_path + name
179
-
180
- @node_cache.fetch_or_store(cache_key) do
181
- lookup_content(name, full_path)
182
- end
183
- end
184
- end
185
-
186
- Traces::Provider(Content) do
187
- def respond(link, request)
188
- attributes = {
189
- "link.key" => link.key,
190
- "link.href" => link.href
191
- }
192
-
193
- Traces.trace("utopia.content.respond", attributes: attributes) {super}
9
+ module Content
10
+ def self.new(...)
11
+ Middleware.new(...)
194
12
  end
195
13
  end
196
14
  end
@@ -17,32 +17,32 @@ A simple CRUD controller might look like:
17
17
  ```ruby
18
18
  prepend Actions
19
19
 
20
- on 'index' do
20
+ on "index" do
21
21
  @users = User.all
22
22
  end
23
23
 
24
- on 'new' do |request|
24
+ on "new" do |request|
25
25
  @user = User.new
26
26
 
27
27
  if request.post?
28
- @user.update_attributes(request.params['user'])
28
+ @user.update_attributes(request.params["user"])
29
29
 
30
30
  redirect! "index"
31
31
  end
32
32
  end
33
33
 
34
- on 'edit' do |request|
35
- @user = User.find(request.params['id'])
34
+ on "edit" do |request|
35
+ @user = User.find(request.params["id"])
36
36
 
37
37
  if request.post?
38
- @user.update_attributes(request.params['user'])
38
+ @user.update_attributes(request.params["user"])
39
39
 
40
40
  redirect! "index"
41
41
  end
42
42
  end
43
43
 
44
- on 'delete' do |request|
45
- User.find(request.params['id']).destroy
44
+ on "delete" do |request|
45
+ User.find(request.params["id"]).destroy
46
46
 
47
47
  redirect! "index"
48
48
  end
@@ -6,7 +6,7 @@
6
6
  require_relative "../http"
7
7
 
8
8
  module Utopia
9
- class Controller
9
+ module Controller
10
10
  # A controller layer which invokes functinality based on the request path.
11
11
  # @example
12
12
  # on '*' do |request, path|
@@ -6,7 +6,7 @@
6
6
  require_relative "../http"
7
7
 
8
8
  module Utopia
9
- class Controller
9
+ module Controller
10
10
  CONTENT_TYPE = HTTP::CONTENT_TYPE
11
11
 
12
12
  # The base implementation of a controller class.
@@ -71,14 +71,14 @@ module Utopia
71
71
  def process!(request, relative_path)
72
72
  return nil
73
73
  end
74
-
74
+
75
75
  # Copy the instance variables from the previous controller to the next controller (usually only a few). This allows controllers to share effectively the same instance variables while still being separate classes/instances.
76
76
  def copy_instance_variables(from)
77
77
  from.instance_variables.each do |name|
78
78
  self.instance_variable_set(name, from.instance_variable_get(name))
79
79
  end
80
80
  end
81
-
81
+
82
82
  # Call into the next app as defined by rack.
83
83
  def call(env)
84
84
  self.class.controller.app.call(env)
@@ -98,7 +98,7 @@ module Utopia
98
98
  def ignore!
99
99
  throw :response, nil
100
100
  end
101
-
101
+
102
102
  # Request relative redirect. Respond with a redirect to the given target.
103
103
  def redirect!(target, status = 302)
104
104
  status = HTTP::Status.new(status, 300...400)
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2009-2025, by Samuel Williams.
5
+
6
+ require_relative "../path"
7
+ require_relative "../middleware"
8
+
9
+ require_relative "variables"
10
+ require_relative "base"
11
+ require_relative "rewrite"
12
+ require_relative "respond"
13
+ require_relative "actions"
14
+
15
+ require "concurrent/map"
16
+
17
+ module Utopia
18
+ # A middleware which loads controller classes and invokes functionality based on the requested path.
19
+ module Controller
20
+ class Middleware
21
+ # The controller filename.
22
+ CONTROLLER_RB = "controller.rb".freeze
23
+
24
+ # @param root [String] The content root where controllers will be loaded from.
25
+ # @param base [Class] The base class for controllers.
26
+ def initialize(app, root: Utopia::default_root, base: Controller::Base)
27
+ @app = app
28
+ @root = root
29
+
30
+ @controller_cache = Concurrent::Map.new
31
+
32
+ @base = base
33
+ end
34
+
35
+ attr :app
36
+
37
+ def freeze
38
+ return self if frozen?
39
+
40
+ @root.freeze
41
+ @base.freeze
42
+
43
+ super
44
+ end
45
+
46
+ # Fetch the controller for the given relative path. May be cached.
47
+ def lookup_controller(path)
48
+ @controller_cache.fetch_or_store(path.to_s) do
49
+ load_controller_file(path)
50
+ end
51
+ end
52
+
53
+ # Loads the controller file for the given relative url_path.
54
+ def load_controller_file(uri_path)
55
+ base_path = File.join(@root, uri_path.components)
56
+
57
+ controller_path = File.join(base_path, CONTROLLER_RB)
58
+ # puts "load_controller_file(#{path.inspect}) => #{controller_path}"
59
+
60
+ if File.exist?(controller_path)
61
+ klass = Class.new(@base)
62
+
63
+ # base_path is expected to be a string representing a filesystem path:
64
+ klass.const_set(:BASE_PATH, base_path.freeze)
65
+
66
+ # uri_path is expected to be an instance of Path:
67
+ klass.const_set(:URI_PATH, uri_path.dup.freeze)
68
+
69
+ klass.const_set(:CONTROLLER, self)
70
+
71
+ klass.class_eval(File.read(controller_path), controller_path)
72
+
73
+ # We lock down the controller class to prevent unsafe modifications:
74
+ klass.freeze
75
+
76
+ # Create an instance of the controller:
77
+ return klass.new
78
+ else
79
+ return nil
80
+ end
81
+ end
82
+
83
+ # Invoke the controller layer for a given request. The request path may be rewritten.
84
+ def invoke_controllers(request)
85
+ request_path = Path.from_string(request.path_info)
86
+
87
+ # The request path must be absolute. We could handle this internally but it is probably better for this to be an error:
88
+ raise ArgumentError.new("Invalid request path #{request_path}") unless request_path.absolute?
89
+
90
+ # The controller path contains the current complete path being evaluated:
91
+ controller_path = Path.new
92
+
93
+ # Controller instance variables which eventually get processed by the view:
94
+ variables = request.env[VARIABLES_KEY]
95
+
96
+ while request_path.components.any?
97
+ # We copy one path component from the relative path to the controller path at a time. The controller, when invoked, can modify the relative path (by assigning to relative_path.components). This allows for controller-relative rewrites, but only the remaining path postfix can be modified.
98
+ controller_path.components << request_path.components.shift
99
+
100
+ if controller = lookup_controller(controller_path)
101
+ # Don't modify the original controller:
102
+ controller = controller.clone
103
+
104
+ # Append the controller to the set of controller variables, updates the controller with all current instance variables.
105
+ variables << controller
106
+
107
+ if result = controller.process!(request, request_path)
108
+ return result
109
+ end
110
+ end
111
+ end
112
+
113
+ # Controllers can directly modify relative_path, which is copied into controller_path. The controllers may have rewriten the path so we update the path info:
114
+ request.env[Rack::PATH_INFO] = controller_path.to_s
115
+
116
+ # No controller gave a useful result:
117
+ return nil
118
+ end
119
+
120
+ def call(env)
121
+ env[VARIABLES_KEY] ||= Variables.new
122
+
123
+ request = Rack::Request.new(env)
124
+
125
+ if result = invoke_controllers(request)
126
+ return result
127
+ end
128
+
129
+ return @app.call(env)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -4,60 +4,16 @@
4
4
  # Copyright, 2016-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "../http"
7
- require_relative "../responder"
7
+ require_relative "responder"
8
8
 
9
9
  module Utopia
10
- class Controller
10
+ module Controller
11
11
  # A controller layer which provides a convenient way to respond to different requested content types. The order in which you add converters matters, as it determines how the incoming Accept: header is mapped, e.g. the first converter is also defined as matching the media range */*.
12
12
  module Respond
13
13
  def self.prepended(base)
14
14
  base.extend(ClassMethods)
15
15
  end
16
16
 
17
- module Handlers
18
- module JSON
19
- APPLICATION_JSON = HTTP::Accept::ContentType.new("application", "json").freeze
20
-
21
- def self.split(*arguments)
22
- APPLICATION_JSON.split(*arguments)
23
- end
24
-
25
- def self.call(context, request, media_range, object, **options)
26
- if version = media_range.parameters["version"]
27
- options[:version] = version.to_s
28
- end
29
-
30
- context.succeed! content: object.to_json(options), type: APPLICATION_JSON
31
- end
32
- end
33
-
34
- module Passthrough
35
- WILDCARD = HTTP::Accept::MediaTypes::MediaRange.new("*", "*").freeze
36
-
37
- def self.split(*arguments)
38
- WILDCARD.split(*arguments)
39
- end
40
-
41
- def self.call(context, request, media_range, object, **options)
42
- # Do nothing.
43
- end
44
- end
45
- end
46
-
47
- class Responder < Utopia::Responder
48
- def with_json
49
- @handlers << Handlers::JSON
50
- end
51
-
52
- def with_passthrough
53
- @handlers << Handlers::Passthrough
54
- end
55
-
56
- def with(content_type, &block)
57
- handle(content_type, &block)
58
- end
59
- end
60
-
61
17
  module ClassMethods
62
18
  def responds
63
19
  @responder ||= Responder.new
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2020-2025, by Samuel Williams.
5
+
6
+ require_relative "middleware"
7
+
8
+ module Utopia
9
+ module Controller
10
+ module Handlers
11
+ module JSON
12
+ APPLICATION_JSON = HTTP::Accept::ContentType.new("application", "json").freeze
13
+
14
+ def self.split(*arguments)
15
+ APPLICATION_JSON.split(*arguments)
16
+ end
17
+
18
+ def self.call(context, request, media_range, object, **options)
19
+ if version = media_range.parameters["version"]
20
+ options[:version] = version.to_s
21
+ end
22
+
23
+ context.succeed! content: object.to_json(options), type: APPLICATION_JSON
24
+ end
25
+ end
26
+
27
+ module Passthrough
28
+ WILDCARD = HTTP::Accept::MediaTypes::MediaRange.new("*", "*").freeze
29
+
30
+ def self.split(*arguments)
31
+ WILDCARD.split(*arguments)
32
+ end
33
+
34
+ def self.call(context, request, media_range, object, **options)
35
+ # Do nothing.
36
+ end
37
+ end
38
+ end
39
+
40
+ class Responder
41
+ Handler = Struct.new(:content_type, :block) do
42
+ def split(*arguments)
43
+ self.content_type.split(*arguments)
44
+ end
45
+
46
+ def call(context, request, media_range, *arguments, **options)
47
+ context.instance_exec(media_range, *arguments, **options, &self.block)
48
+ end
49
+ end
50
+
51
+ Responds = Struct.new(:responder, :context, :request) do
52
+ # @todo Refactor `object` -> `*arguments`...
53
+ def with(object, **options)
54
+ responder.call(context, request, object, **options)
55
+ end
56
+ end
57
+
58
+ def initialize
59
+ @handlers = HTTP::Accept::MediaTypes::Map.new
60
+ end
61
+
62
+ attr :handlers
63
+
64
+ def freeze
65
+ @handlers.freeze
66
+
67
+ super
68
+ end
69
+
70
+ def call(context, request, *arguments, **options)
71
+ # Parse the list of browser preferred content types and return ordered by priority:
72
+ media_types = HTTP::Accept::MediaTypes.browser_preferred_media_types(request.env)
73
+
74
+ handler, media_range = @handlers.for(media_types)
75
+
76
+ if handler
77
+ handler.call(context, request, media_range, *arguments, **options)
78
+ end
79
+ end
80
+
81
+ # Add a converter for the specified content type. Call the block with the response content if the request accepts the specified content_type.
82
+ def handle(content_type, &block)
83
+ @handlers << Handler.new(content_type, block)
84
+ end
85
+
86
+ def respond_to(context, request)
87
+ Responds.new(self, context, request)
88
+ end
89
+
90
+ def with_json
91
+ @handlers << Handlers::JSON
92
+ end
93
+
94
+ def with_passthrough
95
+ @handlers << Handlers::Passthrough
96
+ end
97
+
98
+ def with(content_type, &block)
99
+ handle(content_type, &block)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -19,14 +19,14 @@ rewrite.extract_prefix permalink: /(?<id>\d+)-(?<title>.*)/ do |request, path, m
19
19
  end
20
20
  end
21
21
 
22
- on 'post' do
22
+ on "post" do
23
23
  # You can do further processing here.
24
24
  fail! unless @post.published?
25
25
 
26
26
  @comments = @post.comments.first(5)
27
27
  end
28
28
 
29
- on 'edit' do
29
+ on "edit" do
30
30
  # You can do further processing here.
31
31
  fail! unless @current_user&.editor?
32
32
  end
@@ -7,7 +7,7 @@ require_relative "../http"
7
7
  require_relative "../path/matcher"
8
8
 
9
9
  module Utopia
10
- class Controller
10
+ module Controller
11
11
  # This controller layer rewrites the path before executing controller actions. When the rule matches, the supplied block is executed.
12
12
  # @example
13
13
  # prepend Rewrite
@@ -1,10 +1,12 @@
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-2025, by Samuel Williams.
5
+
6
+ require_relative "../middleware"
5
7
 
6
8
  module Utopia
7
- class Controller
9
+ module Controller
8
10
  # Provides a stack-based instance variable lookup mechanism. It can flatten a stack of controllers into a single hash.
9
11
  class Variables
10
12
  def initialize
@@ -14,7 +16,7 @@ module Utopia
14
16
  def top
15
17
  @controllers.last
16
18
  end
17
-
19
+
18
20
  def << controller
19
21
  if top = self.top
20
22
  # This ensures that most variables will be at the top and controllers can naturally interactive with instance variables:
@@ -42,7 +44,7 @@ module Utopia
42
44
  raise KeyError.new(key)
43
45
  end
44
46
  end
45
-
47
+
46
48
  def to_hash
47
49
  attributes = {}
48
50
 
@@ -56,10 +58,14 @@ module Utopia
56
58
 
57
59
  return attributes
58
60
  end
59
-
61
+
60
62
  def [] key
61
63
  fetch("@#{key}".to_sym, nil)
62
64
  end
63
65
  end
66
+
67
+ def self.[] request
68
+ request.env[VARIABLES_KEY]
69
+ end
64
70
  end
65
71
  end