utopia 3.0.3 → 3.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c16b554d772d132bd1820ec17f402bbdf2d3720a5c6965f8673c0f06054f1c29
4
- data.tar.gz: d6d839b6d55e3aff38507b9c4324d2631eb5a6b7f64eaeddb7e6b30185409b0a
3
+ metadata.gz: '0314485e770b9085839422a7484c179a39e447d81b15b211d70fbb4361fdc8b2'
4
+ data.tar.gz: 6e583362bc02ce48424f8e3adf7b9528bfd0990ea4ff946879d4bfbc95a52624
5
5
  SHA512:
6
- metadata.gz: ab0202e8f8259c62856341332dcc7f37532535e968e90b75dceb4e8370eee7092ac5e7cdc4e3a2072f0394ba87b39994c18fa2ca1f17864d9f04f5974847a9b2
7
- data.tar.gz: 14260960ab36d1cea1cabc02b6c43c29661efefb6e3e1566ef7e61ce87ba9f6525e111f9182e3beeac0a973bf303c147a71d3edc65d1cb75153c3c7377e7f2a8
6
+ metadata.gz: 4a81a495790a61faea6d2ab3873fd4043807134a4dec40b50177318802eefc14484e8dc218373fbf32b8bfe441ff96038ccaa21093acda9610d1ebc3ad6e87bb
7
+ data.tar.gz: 35d548a73022d0307b8a631d3a8c755b1eef9fcb0edb3f7b224ac2ef57b2334018928f3c8a47e4fea6812882ce794973a639af563b5a941627f3cafcce8ca971
checksums.yaml.gz.sig CHANGED
Binary file
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ NPM = ENV["NPM"] || "npm"
7
+
8
+ # Update public components from production JavaScript packages.
9
+ #
10
+ # Packages are copied from their `dist` directory when present, or otherwise
11
+ # from the package root. The `utopia.components` section of `package.json` can
12
+ # specify per-package `include` patterns to select only required files.
13
+ #
14
+ # @parameter root [String] The project root directory.
15
+ def update(root: context.root)
16
+ require "json"
17
+ require "open3"
18
+ require "utopia/components"
19
+
20
+ components = Utopia::Components.new(root)
21
+ production_packages = fetch_production_packages(components.package_root)
22
+
23
+ components.update(production_packages)
24
+ end
25
+
26
+ private
27
+
28
+ def fetch_production_packages(package_root)
29
+ stdout, _status = Open3.capture2(NPM, "ls", "--production", "--json", chdir: package_root.to_s)
30
+ json = JSON.parse(stdout)
31
+
32
+ flatten_package_dependencies(json).sort.uniq
33
+ end
34
+
35
+ def flatten_package_dependencies(json, into = [])
36
+ if json["dependencies"]
37
+ json["dependencies"].each do |name, details|
38
+ into << name
39
+ flatten_package_dependencies(details, into)
40
+ end
41
+ end
42
+
43
+ return into
44
+ end
@@ -17,7 +17,7 @@ $ npm install jquery
17
17
  Copy the distribution files to `public/_components`:
18
18
 
19
19
  ```bash
20
- $ bundle exec bake utopia:node:update
20
+ $ bundle exec bake utopia:components:update
21
21
  ```
22
22
 
23
23
  This will copy the library's distribution files (typically from `node_modules/*/dist/`) to your `public/_components/` directory, making them available for local serving.
@@ -167,7 +167,7 @@ This template would typically be designed with supporting `_page.xnode` and `_he
167
167
 
168
168
  ## Session
169
169
 
170
- The {ruby Utopia::Session} middleware provides session storage using encrypted client-side cookies. The session management uses symmetric private key encryption to store data on the client and avoid tampering.
170
+ The {ruby Utopia::Session} middleware provides session storage using authenticated, encrypted client-side cookies. The session management uses symmetric private key encryption to store data on the client and prevent tampering.
171
171
 
172
172
  ```ruby
173
173
  use Utopia::Session,
@@ -177,7 +177,7 @@ use Utopia::Session,
177
177
  secure: true
178
178
  ```
179
179
 
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.
180
+ All session data is stored on the client, but it is encrypted and authenticated with a key derived from the secret. The client cannot read or modify the data without the secret stored on the server.
181
181
 
182
182
  When the middleware is installed, the session is available on the request:
183
183
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../../../../utopia/content/middleware"
7
+
8
+ require "traces/provider"
9
+
10
+ Traces::Provider(Utopia::Content::Middleware) do
11
+ def respond(link, request, localization: request.localization)
12
+ attributes = {
13
+ "link.key" => link.key,
14
+ "link.href" => link.href,
15
+ "link.locale" => localization&.locale,
16
+ }
17
+
18
+ Traces.trace("utopia.content.respond", attributes: attributes){super}
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../../../../utopia/static/middleware"
7
+
8
+ require "traces/provider"
9
+
10
+ Traces::Provider(Utopia::Static::Middleware) do
11
+ def respond(request, path, extension, content_type, localization: request.localization)
12
+ attributes = {
13
+ path: path,
14
+ locale: localization&.locale,
15
+ }
16
+
17
+ Traces.trace("utopia.static.respond", attributes: attributes){super}
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "utopia/content/middleware"
7
+ require_relative "utopia/static/middleware"
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "fileutils"
7
+ require "json"
8
+ require "pathname"
9
+
10
+ module Utopia
11
+ # Installs JavaScript packages from `node_modules` into the public components directory. Package contents are copied from `dist` when it exists, otherwise from the package root.
12
+ #
13
+ # By default, the complete source directory is installed. Projects can limit an individual package to a set of files using `utopia.components` in their `package.json` file.
14
+ class Components
15
+ # Initialize a component installer for the given project root.
16
+ #
17
+ # @parameter root [String | Pathname] The project root directory.
18
+ def initialize(root)
19
+ @root = Pathname.new(root)
20
+ @package_root = @root + "node_modules"
21
+
22
+ @install_root = @root + "public/_components"
23
+ @configuration = load_configuration
24
+ end
25
+
26
+ # @attribute [Pathname] The directory containing the installed JavaScript packages.
27
+ attr :package_root
28
+
29
+ # Update the specified packages in the public components directory.
30
+ #
31
+ # @parameter package_names [Array(String)] The production package names to install.
32
+ def update(package_names)
33
+ expand_package_paths(@package_root).each do |package_path|
34
+ package_name = package_path.relative_path_from(@package_root).to_s
35
+
36
+ if package_names.include?(package_name)
37
+ install(package_name, package_path)
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # Load the optional per-package installation rules. A missing `package.json`, or a file without `utopia.components`, preserves the default behaviour of copying complete packages.
45
+ # @returns [Hash] The per-package installation rules.
46
+ def load_configuration
47
+ package_path = @root + "package.json"
48
+
49
+ unless package_path.file?
50
+ return {}
51
+ end
52
+
53
+ configuration = JSON.parse(package_path.read).dig("utopia", "components") || {}
54
+
55
+ unless configuration.is_a?(Hash)
56
+ raise ArgumentError, "utopia.components must be an object!"
57
+ end
58
+
59
+ return configuration
60
+ end
61
+
62
+ # Install one package. Distribution directories are preferred because they generally contain the browser-ready form of a package.
63
+ # @parameter package_name [String] The package name relative to `node_modules`.
64
+ # @parameter package_path [Pathname] The package source directory.
65
+ def install(package_name, package_path)
66
+ install_path = @install_root + package_name
67
+ dist_path = package_path + "dist"
68
+
69
+ if dist_path.directory?
70
+ source_path = dist_path
71
+ else
72
+ source_path = package_path
73
+ end
74
+
75
+ configuration = @configuration[package_name]
76
+
77
+ if configuration
78
+ install_selected(package_name, source_path, install_path, configuration)
79
+ else
80
+ FileUtils::Verbose.rm_rf(install_path)
81
+ FileUtils::Verbose.mkpath(install_path.dirname)
82
+ FileUtils::Verbose.cp_r(source_path, install_path)
83
+ end
84
+ end
85
+
86
+ # Install only the files matched by the configured include patterns. Every pattern is resolved before removing the existing installation, so an invalid configuration cannot leave a package partially installed or remove a previously working copy.
87
+ # @parameter package_name [String] The package name relative to `node_modules`.
88
+ # @parameter source_path [Pathname] The package source directory.
89
+ # @parameter install_path [Pathname] The destination directory.
90
+ # @parameter configuration [Hash] The package installation rules.
91
+ def install_selected(package_name, source_path, install_path, configuration)
92
+ unless configuration.is_a?(Hash)
93
+ raise ArgumentError, "utopia.components.#{package_name}.include must be a non-empty array!"
94
+ end
95
+
96
+ include_patterns = configuration["include"]
97
+
98
+ unless include_patterns.is_a?(Array) && include_patterns.any?
99
+ raise ArgumentError, "utopia.components.#{package_name}.include must be a non-empty array!"
100
+ end
101
+
102
+ paths = include_patterns.flat_map do |pattern|
103
+ included_paths(package_name, source_path, pattern)
104
+ end.uniq.sort
105
+
106
+ FileUtils::Verbose.rm_rf(install_path)
107
+
108
+ paths.each do |relative_path|
109
+ source_file = source_path + relative_path
110
+ install_file = install_path + relative_path
111
+
112
+ FileUtils::Verbose.mkpath(install_file.dirname)
113
+ FileUtils::Verbose.cp(source_file, install_file)
114
+ end
115
+ end
116
+
117
+ # Expand one include pattern into files relative to the package source. Directories are excluded so each result can be copied independently.
118
+ # @parameter package_name [String] The package name used in validation errors.
119
+ # @parameter source_path [Pathname] The package source directory.
120
+ # @parameter pattern [String] The include pattern to expand.
121
+ # @returns [Array(String)] The matching file paths relative to the package source.
122
+ def included_paths(package_name, source_path, pattern)
123
+ unless pattern.is_a?(String) && relative_pattern?(pattern)
124
+ raise ArgumentError, "Invalid include pattern for #{package_name}: #{pattern.inspect}"
125
+ end
126
+
127
+ paths = Dir.glob(pattern, base: source_path.to_s).select do |relative_path|
128
+ (source_path + relative_path).file?
129
+ end
130
+
131
+ if paths.empty?
132
+ raise ArgumentError, "Include pattern for #{package_name} matched no files: #{pattern.inspect}"
133
+ end
134
+
135
+ return paths
136
+ end
137
+
138
+ # Determine whether the pattern is contained within the package source. Absolute paths and parent traversal are rejected because they could otherwise copy arbitrary files from outside the package.
139
+ # @parameter pattern [String] The include pattern to validate.
140
+ # @returns [Boolean] Whether the pattern is relative and does not contain parent traversal.
141
+ def relative_pattern?(pattern)
142
+ path = Pathname.new(pattern)
143
+
144
+ if path.absolute?
145
+ return false
146
+ end
147
+
148
+ if path.each_filename.any?{|component| component == ".."}
149
+ return false
150
+ end
151
+
152
+ return true
153
+ end
154
+
155
+ # Enumerate packages in `node_modules`, descending through scoped package directories such as `@socketry` while preserving their scoped names.
156
+ # @parameter root [Pathname] The directory to enumerate.
157
+ # @parameter into [Array(Pathname)] The array into which package paths are appended.
158
+ # @returns [Array(Pathname)] The discovered package directories.
159
+ def expand_package_paths(root, into = [])
160
+ root.children.select(&:directory?).each do |path|
161
+ basename = path.basename.to_s
162
+
163
+ # Handle organisation sub-directories which start with an '@' symbol:
164
+ if basename.start_with?("@")
165
+ expand_package_paths(path, into)
166
+ else
167
+ into << path
168
+ end
169
+ end
170
+
171
+ return into
172
+ end
173
+ end
174
+ end
@@ -82,11 +82,7 @@ module Utopia
82
82
  def text(content)
83
83
  return unless content
84
84
 
85
- if content.respond_to?(:build_markup)
86
- content.build_markup(self)
87
- else
88
- XRB::Markup.append(@output, content)
89
- end
85
+ content.build_markup(self)
90
86
  end
91
87
 
92
88
  # Write a complete tag to the output.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2015-2025, by Samuel Williams.
4
+ # Copyright, 2015-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "link"
7
7
 
@@ -204,8 +204,10 @@ module Utopia
204
204
  def each(locale)
205
205
  return to_enum(:each, locale) unless block_given?
206
206
 
207
- ordered.each do |links|
208
- yield links.find{|link| link.locale == locale}
207
+ @named.each_key do |name|
208
+ if link = lookup(name, locale)
209
+ yield link
210
+ end
209
211
  end
210
212
  end
211
213
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2009-2025, by Samuel Williams.
4
+ # Copyright, 2009-2026, by Samuel Williams.
5
5
 
6
6
  require "xrb/parsers"
7
7
  require "xrb/entities"
@@ -108,9 +108,9 @@ module Utopia
108
108
  # @returns [String] The resulting string.
109
109
  def to_s
110
110
  if @closing_tag
111
- "#{start_location}: #{@opening_tag} was not closed!"
112
- else
113
111
  "#{start_location}: #{@opening_tag} was closed by #{@closing_tag}!"
112
+ else
113
+ "#{start_location}: #{@opening_tag} was not closed!"
114
114
  end
115
115
  end
116
116
  end
@@ -16,7 +16,6 @@ require_relative "tags"
16
16
 
17
17
  require "xrb/template"
18
18
  require "concurrent/map"
19
- require "traces/provider"
20
19
 
21
20
  module Utopia
22
21
  module Content
@@ -66,11 +65,7 @@ module Utopia
66
65
  end
67
66
 
68
67
  attr :root
69
-
70
- # TODO we should remove this method and expose `@links` directly.
71
- def links(path, **options)
72
- @links.index(path, **options)
73
- end
68
+ attr :links
74
69
 
75
70
  # Load and cache a content template.
76
71
  # @parameter path [Utopia::Path | String] The path.
@@ -159,12 +154,7 @@ module Utopia
159
154
  private
160
155
 
161
156
  def lookup_content(name, parent_path)
162
- if String === name && name.index("/")
163
- name = Path.create(name)
164
- end
165
-
166
157
  if Path === name
167
- name = parent_path + name
168
158
  name_path = name.components.dup
169
159
  name_path[-1] += XNODE_EXTENSION
170
160
  else
@@ -195,6 +185,21 @@ module Utopia
195
185
  end
196
186
 
197
187
  def content_tag(name, node, parent_path: node.parent_path)
188
+ # Preserve nested names while searching the physical content hierarchy:
189
+ if String === name
190
+ if name.index("/")
191
+ name = Path.create(name)
192
+ end
193
+ end
194
+
195
+ if Path === name
196
+ cache_key = parent_path + name
197
+
198
+ return @node_cache.fetch_or_store(cache_key) do
199
+ lookup_content(name, parent_path)
200
+ end
201
+ end
202
+
198
203
  full_path = parent_path + name
199
204
 
200
205
  name = full_path.pop
@@ -216,16 +221,5 @@ module Utopia
216
221
  end
217
222
  end
218
223
 
219
- Traces::Provider(Middleware) do
220
- def respond(link, request, localization: request.localization)
221
- attributes = {
222
- "link.key" => link.key,
223
- "link.href" => link.href,
224
- "link.locale" => localization&.locale,
225
- }
226
-
227
- Traces.trace("utopia.content.middleware.respond", attributes: attributes){super}
228
- end
229
- end
230
224
  end
231
225
  end
@@ -51,14 +51,12 @@ module Utopia
51
51
  def local_path(path = ".", base = nil)
52
52
  path = Path[path]
53
53
 
54
- root = Pathname.new(@controller.root)
55
-
56
- if path.absolute?
57
- return root.join(*path.components)
58
- else
54
+ if path.relative?
59
55
  base ||= uri_path.dirname
60
- return root.join(*(base + path).components)
56
+ path = base + path
61
57
  end
58
+
59
+ return Pathname.new(path.to_url_path.local_path(@controller.root))
62
60
  end
63
61
 
64
62
  # Resolve a path relative to this node's containing URI path.
@@ -84,7 +82,7 @@ module Utopia
84
82
  def links(path = ".", **options, &block)
85
83
  path = uri_path.dirname + Path[path]
86
84
 
87
- links = @controller.links(path, **options)
85
+ links = @controller.links.index(path, **options)
88
86
 
89
87
  if block_given?
90
88
  links.each(&block)
@@ -96,7 +94,7 @@ module Utopia
96
94
  # Return localized and indexed variants related to this node.
97
95
  # @returns [Array(Link)] The related links.
98
96
  def related_links
99
- @controller.links(@uri_path.dirname, name: @uri_path.basename, indices: true)
97
+ @controller.links.index(@uri_path.dirname, name: @uri_path.basename, indices: true)
100
98
  end
101
99
 
102
100
  # Return the directory whose links are siblings of this node.
@@ -113,7 +111,7 @@ module Utopia
113
111
  # @parameter options [Hash] The options.
114
112
  # @returns [Array(Link)] The sibling links.
115
113
  def sibling_links(**options)
116
- return @controller.links(siblings_path, **options)
114
+ return @controller.links.index(siblings_path, **options)
117
115
  end
118
116
 
119
117
  # Lookup the given tag which is being rendered within the given node. Invoked by {Document}.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2015-2025, by Samuel Williams.
4
+ # Copyright, 2015-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "../http"
7
7
  require_relative "../path/matcher"
@@ -48,6 +48,8 @@ module Utopia
48
48
  # Freeze this object and its internal state.
49
49
  # @returns [self] This object.
50
50
  def freeze
51
+ return self if frozen?
52
+
51
53
  @matcher.freeze
52
54
  @block.freeze
53
55
 
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Utopia
7
+ module Exceptions
8
+ # Exceptions raised by application code which can be safely handled and reported.
9
+ APPLICATION_ERRORS = [StandardError, ScriptError].freeze
10
+ end
11
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2014-2025, by Samuel Williams.
4
+ # Copyright, 2014-2026, by Samuel Williams.
5
5
  # Copyright, 2025, by Olle Jonsson.
6
6
 
7
7
  require "console"
@@ -9,6 +9,7 @@ require "console"
9
9
  require_relative "../middleware"
10
10
  require_relative "../request"
11
11
  require_relative "../response"
12
+ require_relative "application_errors"
12
13
 
13
14
  module Utopia
14
15
  module Exceptions
@@ -37,7 +38,7 @@ module Utopia
37
38
  def call(request)
38
39
  begin
39
40
  return @delegate.call(request)
40
- rescue Exception => exception
41
+ rescue *APPLICATION_ERRORS => exception
41
42
  Console.warn(self, "An error occurred while processing the request.", error: exception)
42
43
 
43
44
  begin
@@ -52,7 +53,7 @@ module Utopia
52
53
  error_response.status = 500
53
54
 
54
55
  return error_response
55
- rescue Exception => exception
56
+ rescue *APPLICATION_ERRORS => exception
56
57
  # If redirection fails, we also finish with a fatal error:
57
58
  Console.error(self, "An error occurred while invoking the error handler.", error: exception)
58
59
  return Response[500, {"content-type" => "text/plain"}, ["An error occurred while processing the request."]]