utopia 2.32.1 → 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 +1 -1
  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 -171
  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 +30 -5
  69. data/releases.md +12 -1
  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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39b95a5f8c45c77816846900c9d99e7bb306bdbc589c60b10e71d045260d6107
4
- data.tar.gz: 1c82a315db0de1c3738c6a03a4569fadaaf0aa031850f672976b29f71fd70087
3
+ metadata.gz: b0a73ea69813127b09be72632c42f1ce6a6a4e94678c823575125223ae19b520
4
+ data.tar.gz: 8c50126b47f9184bce9237dade14818cf70d959f0aae2fb34f8fb486a22a0d0d
5
5
  SHA512:
6
- metadata.gz: 01e866be2be3f5465b69a1de18ef976b2e4edbaa5facc9a168291e87965039afdc149df0157b7f50236ecd453a86b2ad93d34e548809dc3ae08b44c4a21a62f6
7
- data.tar.gz: d8c87f177f7572795bf416c9ec6b560e2b3e357446c86d0853b8f47094d56e46413b112953b53bfa2cc27b6abc15baa3973f2862d21928f8056b46947cd66419
6
+ metadata.gz: 12bf144743c5b6cae0f147e60d37815c081025a86c6ac7c4146fbb392087819ddafa80c431579855ddd1e7b565fbc24a00902df3bc56222e6d218e48e5eade98
7
+ data.tar.gz: 76d0216724af21e389d71b6236ab36ebf5810f6365a536f000e6601d83c8df03a451a9e5eecc2cdcb045c8c1b206018a02a4762433e2e6b227a1508dd6df34f2
checksums.yaml.gz.sig CHANGED
@@ -1 +1 @@
1
- Vҿ�Op�ғ����9�ر5��Zm��Jn#jP���":���9q[�#����e]e�b��ܿ�ۤ���� ԁ��9 ſj�\�1����]MV?CKt=�V���P//�
1
+ <,z ��x��f�����e�>zr<��(���nI7r�_`���8ҍ��,+8�/���N�",4RD���0�I7T�]Ձ2]�%�0��q�E��2P�ډ �� /xA{coXK������)bѐ�9I(?�!}?~2qOҿt���}�mTcv%+�u���n��0X������n�z����{ʣA�/U���޽�M���nw_�+�md�3G�9'T87�GeA|��*�e�6J[>��2L�Z,7TC���p��&U-�x�%ۤ�c<#����d�4DŽ�������Hé�\�
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2025, by Samuel Williams.
4
+ # Copyright, 2018-2026, by Samuel Williams.
5
5
 
6
6
  # Update environment variables in config/environment.yaml
7
7
  def initialize(...)
@@ -24,7 +24,7 @@ end
24
24
  # @parameter root [String] The root directory of the project.
25
25
  def defaults(name, root: context.root)
26
26
  update_environment(root, name) do |store|
27
- Console.info(self) {"Setting up defaults for environment #{name}..."}
27
+ Console.info(self){"Setting up defaults for environment #{name}..."}
28
28
  # Set some useful defaults for the environment.
29
29
  store["UTOPIA_SESSION_SECRET"] ||= SecureRandom.hex(40)
30
30
  end
@@ -40,10 +40,10 @@ def update(name, root: context.root, **variables)
40
40
  key = key.to_s
41
41
 
42
42
  if value && !value.empty?
43
- Console.info(self) {"ENV[#{key.inspect}] will default to #{value.inspect} unless otherwise specified."}
43
+ Console.info(self){"ENV[#{key.inspect}] will default to #{value.inspect} unless otherwise specified."}
44
44
  store[key] = value
45
45
  else
46
- Console.info(self) {"ENV[#{key.inspect}] will be unset unless otherwise specified."}
46
+ Console.info(self){"ENV[#{key.inspect}] will be unset unless otherwise specified."}
47
47
  store.delete(key)
48
48
  end
49
49
  end
@@ -63,10 +63,10 @@ def read(name, root: context.root)
63
63
  environment_path = self.environment_path(root, name)
64
64
 
65
65
  if File.exist?(environment_path)
66
- Console.debug(self) {"Loading environment #{name} from #{environment_path}..."}
66
+ Console.debug(self){"Loading environment #{name} from #{environment_path}..."}
67
67
  YAML.load_file(environment_path)
68
68
  else
69
- Console.debug(self) {"No environment #{name} found at #{environment_path}."}
69
+ Console.debug(self){"No environment #{name} found at #{environment_path}."}
70
70
  {}
71
71
  end
72
72
  end
@@ -31,7 +31,10 @@ SERVER_ROOT = File.join(SETUP_ROOT, "server")
31
31
  def update(root: context.root)
32
32
  # It's okay to call this on an existing repo, it will only update config as required to enable --shared.
33
33
  # --shared allows multiple users to access the site with the same group.
34
- system("git", "init", "--shared", chdir: root) or fail "could not initialize repository"
34
+ arguments = ["git", "init", "--shared"]
35
+ arguments << "--initial-branch=main" unless File.exist?(File.join(root, ".git"))
36
+
37
+ system(*arguments, chdir: root) or fail "could not initialize repository"
35
38
 
36
39
  system("git", "config", "receive.denyCurrentBranch", "ignore", chdir: root) or fail "could not set configuration"
37
40
  system("git", "config", "core.worktree", root, chdir: root) or fail "could not set configuration"
data/bake/utopia/site.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2025, by Samuel Williams.
4
+ # Copyright, 2017-2026, by Samuel Williams.
5
5
  # Copyright, 2020, by Michael Adams.
6
6
 
7
7
  def initialize(...)
@@ -18,20 +18,20 @@ end
18
18
  SETUP_ROOT = File.expand_path("../../setup", __dir__)
19
19
 
20
20
  # Configuration files which should be installed/updated:
21
- CONFIGURATION_FILES = [".gitignore", "config.ru", "config/environment.rb", "falcon.rb", "gems.rb", "Guardfile", "bake.rb", "test/website.rb", "fixtures/website.rb"]
21
+ CONFIGURATION_FILES = [".gitignore", "config/application.rb", "config/environment.rb", "config/serve.rb", "falcon.rb", "gems.rb", "bake.rb", "test/website.rb", "fixtures/website.rb"]
22
22
 
23
23
  # Directories that should exist:
24
24
  DIRECTORIES = ["config", "lib", "pages", "public", "bake", "fixtures", "test"]
25
25
 
26
26
  # Directories that should be removed during upgrade process:
27
- OLD_PATHS = ["access_log", "cache", "tmp", "Rakefile", "tasks", ".bowerrc"]
27
+ OLD_PATHS = ["access_log", "cache", "tmp", "Rakefile", "tasks", ".bowerrc", "Guardfile"]
28
28
 
29
29
  # The root directory of the template site:
30
30
  SITE_ROOT = File.join(SETUP_ROOT, "site")
31
31
 
32
32
  # Create a new local Utopia website using the default template.
33
33
  def create(root: context.root)
34
- Console.debug(self) {"Setting up site in #{root} for Utopia v#{Utopia::VERSION}..."}
34
+ Console.debug(self){"Setting up site in #{root} for Utopia v#{Utopia::VERSION}..."}
35
35
 
36
36
  DIRECTORIES.each do |directory|
37
37
  FileUtils.mkdir_p(File.join(root, directory))
@@ -57,9 +57,9 @@ def create(root: context.root)
57
57
 
58
58
  if File.exist?(destination_path)
59
59
  buffer = File.read(destination_path).gsub("$UTOPIA_VERSION", Utopia::VERSION)
60
- File.open(destination_path, "w") {|file| file.write(buffer)}
60
+ File.open(destination_path, "w"){|file| file.write(buffer)}
61
61
  else
62
- Console.warn(self) {"Could not open #{destination_path}, maybe it should be removed from CONFIGURATION_FILES?"}
62
+ Console.warn(self){"Could not open #{destination_path}, maybe it should be removed from CONFIGURATION_FILES?"}
63
63
  end
64
64
  end
65
65
 
@@ -67,10 +67,10 @@ def create(root: context.root)
67
67
 
68
68
  context.lookup("utopia:environment:setup").call(root: root)
69
69
 
70
- if !File.exist?(".git")
71
- Console.info(self) {"Setting up git repository..."}
70
+ if !File.exist?(File.join(root, ".git"))
71
+ Console.info(self){"Setting up git repository..."}
72
72
 
73
- system("git", "init", chdir: root) or warn "could not create git repository"
73
+ system("git", "init", "--initial-branch=main", chdir: root) or warn "could not create git repository"
74
74
  system("git", "add", ".", chdir: root) or warn "could not add all files"
75
75
  system("git", "commit", "-q", "-m", "Initial Utopia v#{Utopia::VERSION} site.", chdir: root) or warn "could not commit files"
76
76
  end
@@ -95,7 +95,7 @@ end
95
95
  def upgrade(root: context.root)
96
96
  message = "Upgrade to utopia v#{Utopia::VERSION}."
97
97
 
98
- Console.info(self) {"Upgrading #{root}..."}
98
+ Console.info(self){"Upgrading #{root}..."}
99
99
 
100
100
  commit_changes(root, message) do
101
101
  DIRECTORIES.each do |directory|
@@ -106,7 +106,7 @@ def upgrade(root: context.root)
106
106
  path = File.join(root, path)
107
107
 
108
108
  if File.exist?(path)
109
- Console.info(self) {"Removing #{path}..."}
109
+ Console.info(self){"Removing #{path}..."}
110
110
  FileUtils.rm_rf(path)
111
111
  end
112
112
  end
@@ -115,11 +115,11 @@ def upgrade(root: context.root)
115
115
  source_path = File.join(SITE_ROOT, configuration_file)
116
116
  destination_path = File.join(root, configuration_file)
117
117
 
118
- Console.info(self) {"Updating #{destination_path}..."}
118
+ Console.info(self){"Updating #{destination_path}..."}
119
119
 
120
120
  FileUtils.copy_entry(source_path, destination_path)
121
121
  buffer = File.read(destination_path).gsub("$UTOPIA_VERSION", Utopia::VERSION)
122
- File.open(destination_path, "w") {|file| file.write(buffer)}
122
+ File.open(destination_path, "w"){|file| file.write(buffer)}
123
123
  end
124
124
 
125
125
  context.lookup("utopia:environment:setup").call(root: root)
@@ -166,7 +166,7 @@ def move_static!(root)
166
166
  if File.lstat(new_static_path).symlink?
167
167
  FileUtils.rm_f new_static_path
168
168
  else
169
- Console.warn(self) {"Can't move pages/_static to public/_static, destination already exists."}
169
+ Console.warn(self){"Can't move pages/_static to public/_static, destination already exists."}
170
170
  return
171
171
  end
172
172
  end
@@ -3,22 +3,29 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2017-2025, by Samuel Williams.
5
5
 
6
- def generate(output_path: "static")
6
+ # Generate a static copy of the application.
7
+ # @parameter output_path [String] The output path for the generated site.
8
+ # @parameter application_path [String] The application configuration path.
9
+ # @parameter public_path [String] The public assets path.
10
+ # @parameter force [Boolean] Remove the output directory before generating the site.
11
+ def generate(output_path: "static", application_path: "config/application.rb", public_path: "public", force: true)
7
12
  require "falcon/server"
8
- require "async/io"
9
13
  require "async/http/endpoint"
10
14
  require "async/container"
15
+ require "fileutils"
16
+ require "utopia/application"
11
17
 
12
- config_path = File.join(Dir.pwd, "config.ru")
18
+ application_path = File.expand_path(application_path, Dir.pwd)
19
+ public_path = File.expand_path(public_path, Dir.pwd)
13
20
  container_class = Async::Container::Threaded
14
21
  server_port = 9090
15
22
 
16
- app, options = Rack::Builder.parse_file(config_path)
23
+ app = Utopia::Application.load(application_path)
17
24
 
18
- container = container_class.run(count: 2) do
25
+ container = container_class.run(count: 1) do
19
26
  Async do
20
27
  server = Falcon::Server.new(
21
- Falcon::Server.middleware(app),
28
+ Falcon::Server.protocol_middleware(app),
22
29
  Async::HTTP::Endpoint.parse("http://localhost:#{server_port}")
23
30
  )
24
31
 
@@ -28,17 +35,23 @@ def generate(output_path: "static")
28
35
 
29
36
  output_path = File.expand_path(output_path, Dir.pwd)
30
37
 
31
- # Delete any existing stuff:
32
- FileUtils.rm_rf(output_path)
38
+ # Delete existing output when explicitly requested:
39
+ if force
40
+ FileUtils.rm_rf(output_path)
41
+ end
33
42
 
34
43
  # Copy all public assets:
35
44
  FileUtils::Verbose.mkpath(output_path)
36
- Dir.glob(File.join(Dir.pwd, "public/*")) do |path|
45
+ Dir.glob(File.join(public_path, "*")) do |path|
37
46
  FileUtils::Verbose.cp_r(path, output_path)
38
47
  end
39
48
 
40
- # Generate HTML pages:
41
- system("wget", "--mirror", "--recursive", "--continue", "--convert-links", "--adjust-extension", "--no-host-directories", "--directory-prefix", output_path.to_s, "http://localhost:#{server_port}")
42
-
43
- container.stop
49
+ begin
50
+ # Generate HTML pages:
51
+ unless system("wget", "--mirror", "--recursive", "--continue", "--convert-links", "--adjust-extension", "--no-host-directories", "--directory-prefix", output_path.to_s, "http://localhost:#{server_port}")
52
+ raise "Static site generation failed!"
53
+ end
54
+ ensure
55
+ container.stop
56
+ end
44
57
  end
data/bake/utopia.rb CHANGED
@@ -15,5 +15,5 @@ end
15
15
  def development
16
16
  self.environment
17
17
 
18
- exec("guard", "-g", "development")
18
+ exec("falcon", "serve")
19
19
  end
@@ -4,7 +4,7 @@ This guide explains how to set up a `utopia` website for local development and d
4
4
 
5
5
  ## Installation
6
6
 
7
- Utopia is built on Ruby and Rack. Therefore, Ruby (suggested 2.0+) should be installed and working. Then, to install `utopia` and all required dependencies, run:
7
+ Utopia is built on Ruby. Therefore, Ruby should be installed and working. Then, to install `utopia` and all required dependencies, run:
8
8
 
9
9
  ~~~ bash
10
10
  $ gem install utopia
@@ -32,10 +32,12 @@ You will now have a basic template site running on `https://localhost:9292`.
32
32
  Utopia includes a redirection middleware to redirect all root-level requests to a given URI. The default being `/welcome/index`:
33
33
 
34
34
  ```ruby
35
- # in config.ru
35
+ # in config/application.rb
36
36
 
37
- use Utopia::Redirection::Rewrite,
38
- "/" => "/welcome/index"
37
+ Application = Utopia::Application.build do
38
+ use Utopia::Redirection::Rewrite,
39
+ "/" => "/welcome/index"
40
+ end
39
41
  ```
40
42
 
41
43
  The content for this page is stored in `pages/welcome/index.xnode`. The format of this page is a subset of HTML5 - open and close tags are strictly enforced.
@@ -84,7 +86,7 @@ website
84
86
 
85
87
  Least Coverage:
86
88
  pages/_page.xnode: 6 lines not executed!
87
- config.ru: 4 lines not executed!
89
+ config/application.rb: 4 lines not executed!
88
90
  pages/welcome/index.xnode: 2 lines not executed!
89
91
  pages/_heading.xnode: 1 lines not executed!
90
92
 
data/context/index.yaml CHANGED
@@ -13,8 +13,7 @@ files:
13
13
  and deployment.
14
14
  - path: middleware.md
15
15
  title: Middleware
16
- description: This guide gives an overview of the different Rack middleware used
17
- by Utopia.
16
+ description: This guide gives an overview of the different middleware used by Utopia.
18
17
  - path: server-setup.md
19
18
  title: Server Setup
20
19
  description: This guide explains how to deploy a `utopia` web application.
@@ -1,16 +1,16 @@
1
1
  # Middleware
2
2
 
3
- This guide gives an overview of the different Rack middleware used by Utopia.
3
+ This guide gives an overview of the different middleware used by Utopia.
4
4
 
5
5
  ## Static
6
6
 
7
- The {ruby Utopia::Static} middleware services static files efficiently. By default, it works with `Rack::Sendfile` and supports `ETag` based caching. Normally, you'd prefer to put static files into `public/_static` but it's also acceptable to put static content into `pages/` if it makes sense.
7
+ The {ruby Utopia::Static} middleware services static files efficiently and supports `ETag` based caching. Normally, you'd prefer to put static files into `public/_static` but it's also acceptable to put static content into `pages/` if it makes sense.
8
8
 
9
9
  ~~~ ruby
10
10
  use Utopia::Static,
11
11
  # The root path to serve files from:
12
12
  root: "path/to/root",
13
- # The mime-types to recognize/serve:
13
+ # The file extension groups to recognize/serve:
14
14
  types: [:default, :xiph],
15
15
  # Cache-Control header for files:
16
16
  cache_control: 'public, max-age=7200'
@@ -29,14 +29,18 @@ use Utopia::Redirection::Rewrite,
29
29
  use Utopia::Redirection::DirectoryIndex,
30
30
  index: 'index.html'
31
31
 
32
- # Redirect (error) status codes to actual pages:
32
+ ~~~
33
+
34
+ Place {ruby Utopia::Redirection::Errors} after all client-visible redirection middleware. It maps unhandled error responses to internal error documents while retaining the original response status. Because its internal requests invoke the downstream application directly, redirects configured before it are bypassed:
35
+
36
+ ~~~ ruby
33
37
  use Utopia::Redirection::Errors,
34
38
  404 => '/errors/file-not-found'
35
39
  ~~~
36
40
 
37
41
  ## Localization
38
42
 
39
- The {ruby Utopia::Localization} middleware provides non-intrusive localization on top of the controller and view layers. The middleware uses the `accept-language` header to guess the preferred locale out of the given options. If a request path maps to a resource, that resource is returned. Otherwise, a non-localized request is made.
43
+ The {ruby Utopia::Localization} middleware computes immutable localization preferences from the request path, host, and `accept-language` header. Localization-aware resource middleware, including {ruby Utopia::Static} and {ruby Utopia::Content}, resolves those preferences without invoking controllers more than once. Place the localization middleware before those resources in the middleware stack.
40
44
 
41
45
  ~~~ ruby
42
46
  use Utopia::Localization,
@@ -53,7 +57,7 @@ pages/index.ja.xnode
53
57
  pages/index.zh.xnode
54
58
  ~~~
55
59
 
56
- You can also access the current locale in the view via {ruby Utopia::Content::Node::Context#localization}.
60
+ You can access the selected locale in a view using `localization.locale`. Controllers can inspect the request preferences using `request.localization`.
57
61
 
58
62
  ## Controller
59
63
 
@@ -73,7 +77,7 @@ A controller is a file within the specified root directory (typically `pages`) w
73
77
  def passthrough(request, path)
74
78
  # Call one of:
75
79
 
76
- # This will cause the middleware to generate a response.
80
+ # Respond immediately with a complete Protocol::HTTP::Response.
77
81
  # def respond!(response)
78
82
 
79
83
  # This will cause the controller to skip the request.
@@ -85,17 +89,36 @@ def passthrough(request, path)
85
89
  # Controller relative redirect.
86
90
  # def goto!(target, status = 302)
87
91
 
88
- # Respond with an error which indiciates some kind of failure.
92
+ # Respond with an error which indicates some kind of failure.
89
93
  # def fail!(error = 400, message = nil)
90
94
 
91
- # Succeed the request and immediately respond.
92
- # def succeed!(status: 200, headers: {}, **options)
93
- # options may include content: string or body: Enumerable (as per Rack specifications
95
+ # Succeed with a semantic value which the Respond layer serializes.
96
+ # def succeed!(value = nil, status: 200, headers: {})
94
97
 
95
- suceed!
98
+ succeed!
96
99
  end
97
100
  ```
98
101
 
102
+ Controllers which return semantic values should prepend {ruby Utopia::Controller::Respond} and configure serializers. Serializer blocks return wire-ready response bodies, so they can return streaming {ruby Protocol::HTTP::Body::Readable} objects without buffering them.
103
+
104
+ ```ruby
105
+ prepend Utopia::Controller::Respond, Utopia::Controller::Actions
106
+
107
+ responds.with("application/json") do |media_range, value|
108
+ JSON.dump(value)
109
+ end
110
+
111
+ on "show" do
112
+ succeed!({"name" => "Samuel"})
113
+ end
114
+ ```
115
+
116
+ Use `respond!` when a controller has already constructed a complete response and no content negotiation is required:
117
+
118
+ ```ruby
119
+ respond! Utopia::Response[200, {"content-type" => "text/plain"}, ["Hello World"]]
120
+ ```
121
+
99
122
  The controller layer can do more complex operations by prepending modules into it.
100
123
 
101
124
  ```ruby
@@ -108,7 +131,7 @@ end
108
131
 
109
132
  on "edit" do |request, path|
110
133
  if request.post?
111
- @user.update_attributes(request[:user])
134
+ @user.update_attributes(parse_body(request)["user"])
112
135
  end
113
136
  end
114
137
 
@@ -155,3 +178,9 @@ use Utopia::Session,
155
178
  ```
156
179
 
157
180
  All session data is stored on the client, but it's encrypted with a salt and the secret key. It is impossible for the client to decrypt the data without the secret stored on the server.
181
+
182
+ When the middleware is installed, the session is available on the request:
183
+
184
+ ```ruby
185
+ request.session[:user_id] = user.id
186
+ ```
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/http/middleware"
7
+ require "protocol/http/middleware/builder"
8
+
9
+ require_relative "request"
10
+ require_relative "response"
11
+
12
+ module Utopia
13
+ # The protocol-facing entrypoint for a Utopia application.
14
+ #
15
+ # This object accepts {Protocol::HTTP::Request} instances, dispatches to the
16
+ # Utopia application stack, and normalizes the result back to a
17
+ # {Protocol::HTTP::Response}.
18
+ class Application < Protocol::HTTP::Middleware
19
+ PATH = "config/application.rb".freeze
20
+
21
+ # Build a Utopia application stack using the protocol HTTP middleware builder.
22
+ # @parameter default_app [Protocol::HTTP::Middleware] The default application used when the block does not call `run`.
23
+ # @parameter block [Proc] The middleware builder block.
24
+ # @returns [Application] The protocol-facing Utopia application.
25
+ def self.build(default_app = Response::NotFound, &block)
26
+ builder = Protocol::HTTP::Middleware::Builder.new(default_app)
27
+
28
+ if block
29
+ if block.arity.zero?
30
+ builder.instance_exec(&block)
31
+ else
32
+ block.call(builder)
33
+ end
34
+ end
35
+
36
+ return self.new(builder.to_app)
37
+ end
38
+
39
+ # Build the default Utopia application.
40
+ # @returns [Application] The default protocol-facing Utopia application.
41
+ def self.default
42
+ self.build
43
+ end
44
+
45
+ # Load a Utopia application from a conventional configuration file.
46
+ #
47
+ # If the file defines an `Application` constant, it will be returned
48
+ # directly. If the constant is a class, it will be instantiated.
49
+ # If the file does not exist, or does not define `Application`, the default
50
+ # application is returned.
51
+ #
52
+ # @parameter path [String] The application configuration path.
53
+ # @parameter options [Hash] Options passed to the application constructor.
54
+ # @returns [Protocol::HTTP::Middleware] The loaded protocol-facing application.
55
+ def self.load(path = PATH, **options)
56
+ if File.exist?(path)
57
+ top = Module.new
58
+ top.class_eval(File.read(path), path)
59
+
60
+ if top.const_defined?(:Application, false)
61
+ application = top.const_get(:Application)
62
+
63
+ if application.is_a?(Class)
64
+ return application.new(**options)
65
+ else
66
+ return application
67
+ end
68
+ end
69
+ end
70
+
71
+ return self.default
72
+ end
73
+
74
+ # Process a protocol HTTP request.
75
+ # @parameter request [Protocol::HTTP::Request] The incoming protocol request.
76
+ # @returns [Protocol::HTTP::Response] The normalized protocol response.
77
+ def call(request)
78
+ request = Request.new(request)
79
+
80
+ return Response.wrap(super(request))
81
+ end
82
+ end
83
+ end
@@ -11,6 +11,11 @@ module Utopia
11
11
 
12
12
  # A builder for rendering Utopia content that extends XRB::Builder with Utopia-specific functionality.
13
13
  class Builder < XRB::Builder
14
+ # Initialize rendering state for a content node.
15
+ # @parameter parent [Builder | Nil] The enclosing builder state.
16
+ # @parameter tag [XRB::Tag | Nil] The tag that opened this state.
17
+ # @parameter node [Utopia::Content::Node] The content node.
18
+ # @parameter attributes [Hash] The attributes.
14
19
  def initialize(parent, tag, node, attributes = tag.to_hash, **options)
15
20
  super(**options)
16
21
 
@@ -35,16 +40,26 @@ module Utopia
35
40
 
36
41
  attr :deferred
37
42
 
43
+ # Insert a deferred-content marker and retain its rendering block.
44
+ # @parameter value [Object | Nil] An unused compatibility argument.
45
+ # @yields {|document| ...} The deferred rendering operation.
46
+ # @returns [String] The closed deferred-content tag.
38
47
  def defer(value = nil, &block)
39
48
  @deferred << block
40
49
 
41
50
  XRB::Tag.closed(DEFERRED_TAG_NAME, :id => @deferred.size - 1)
42
51
  end
43
52
 
53
+ # Fetch an attribute for the current content node.
54
+ # @parameter key [String | Symbol] The lookup key.
55
+ # @returns [Object | Nil] The attribute value.
44
56
  def [](key)
45
57
  @attributes[key]
46
58
  end
47
59
 
60
+ # Render this node's captured content into a document.
61
+ # @parameter document [Utopia::Content::Document] The content document.
62
+ # @returns [String] The rendered output buffer.
48
63
  def call(document)
49
64
  @content = @output.dup
50
65
  @output.clear
@@ -74,6 +89,9 @@ module Utopia
74
89
  end
75
90
  end
76
91
 
92
+ # Write a complete tag to the output.
93
+ # @parameter tag [XRB::Tag] The tag.
94
+ # @returns [String] The output buffer.
77
95
  def tag_complete(tag)
78
96
  tag.write(@output)
79
97
  end
@@ -83,11 +101,18 @@ module Utopia
83
101
  @tags.empty?
84
102
  end
85
103
 
104
+ # Tag begin.
105
+ # @parameter tag [XRB::Tag] The opening tag.
106
+ # @returns [String] The output buffer.
86
107
  def tag_begin(tag)
87
108
  @tags << tag
88
109
  tag.write_opening_tag(@output)
89
110
  end
90
111
 
112
+ # Tag end.
113
+ # @parameter tag [XRB::Tag] The closing tag.
114
+ # @returns [String] The output buffer.
115
+ # @raises [UnbalancedTagError] If the closing tag does not match the most recent opening tag.
91
116
  def tag_end(tag)
92
117
  raise UnbalancedTagError.new(tag) unless @tags.pop.name == tag.name
93
118
  tag.write_closing_tag(@output)