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
@@ -8,7 +8,9 @@ require_relative "response"
8
8
  require_relative "markup"
9
9
 
10
10
  module Utopia
11
- class Content
11
+ module Content
12
+ DEFERRED_TAG_NAME = "utopia:deferred".freeze
13
+
12
14
  # This error is raised if a tag doesn't match up when parsing.
13
15
  class UnbalancedTagError < StandardError
14
16
  def initialize(tag)
@@ -81,34 +83,34 @@ module Utopia
81
83
  def localization
82
84
  @localization ||= Utopia::Localization[request]
83
85
  end
84
-
86
+
85
87
  def parse_markup(markup)
86
88
  MarkupParser.parse(markup, self)
87
89
  end
88
-
90
+
89
91
  # The Rack::Request for this document.
90
92
  attr :request
91
-
93
+
92
94
  # Per-document global attributes.
93
95
  attr :attributes
94
-
96
+
95
97
  # The current state, represents a list from outer to inner most tag by traversing {State#parent}.
96
98
  # At any point in parsing markup, this is a list of the inner most tag,
97
99
  # then the next outer tag, etc.
98
100
  attr :current
99
-
101
+
100
102
  # The first {State} generated by rendering this document. It contains useful information
101
103
  # regarding the node and uri used to access the resource.
102
104
  attr :first
103
-
105
+
104
106
  # End tags represents a list of execution order. This is the order that end tags
105
107
  # have appeared when evaluating nodes.
106
108
  attr :end_tags
107
-
109
+
108
110
  def tag(name, attributes = {})
109
111
  # If we provide a block which can give inner data, we are not self-closing.
110
112
  tag = Tag.new(name, !block_given?, attributes)
111
-
113
+
112
114
  if block_given?
113
115
  node = tag_begin(tag)
114
116
  yield node
@@ -117,10 +119,10 @@ module Utopia
117
119
  tag_complete(tag, node)
118
120
  end
119
121
  end
120
-
122
+
121
123
  def tag_complete(tag, node = nil)
122
124
  node ||= lookup_tag(tag)
123
-
125
+
124
126
  if node
125
127
  tag_begin(tag, node)
126
128
  tag_end(tag)
@@ -128,22 +130,22 @@ module Utopia
128
130
  @current.tag_complete(tag)
129
131
  end
130
132
  end
131
-
133
+
132
134
  def tag_begin(tag, node = nil)
133
135
  node ||= lookup_tag(tag)
134
-
136
+
135
137
  if node
136
138
  @current = State.new(@current, tag, node)
137
-
139
+
138
140
  node.tag_begin(self, state) if node.respond_to?(:tag_begin)
139
-
141
+
140
142
  return node
141
143
  end
142
-
144
+
143
145
  # raise ArgumentError.new("tag_begin: #{tag} is tag.self_closed?") if tag.self_closed?
144
-
146
+
145
147
  @current.tag_begin(tag)
146
-
148
+
147
149
  return nil
148
150
  end
149
151
 
@@ -152,35 +154,35 @@ module Utopia
152
154
  end
153
155
 
154
156
  alias cdata write
155
-
157
+
156
158
  def text(string)
157
159
  @current.text(string)
158
160
  end
159
-
161
+
160
162
  def tag_end(tag = nil)
161
163
  # Determine if the current state contains tags that need to be completed, or if the state itself is finished.
162
164
  if @current.empty?
163
165
  if node = @current.node
164
166
  node.tag_end(self, @current) if node.respond_to?(:tag_end)
165
167
  end
166
-
168
+
167
169
  @end_tags << @current
168
170
  buffer = @current.call(self)
169
-
171
+
170
172
  @current = @current.parent
171
173
  @end_tags.pop
172
-
174
+
173
175
  @current.write(buffer) if @current
174
-
176
+
175
177
  return buffer
176
178
  else
177
179
  # raise ArgumentError.new("tag_begin: #{tag} is tag.self_closed?") if tag.self_closed?
178
180
  @current.tag_end(tag)
179
181
  end
180
-
182
+
181
183
  return nil
182
184
  end
183
-
185
+
184
186
  def render_node(node, attributes = {})
185
187
  @current = State.new(@current, nil, node, attributes)
186
188
 
@@ -190,7 +192,7 @@ module Utopia
190
192
  # This returns the content of rendering the tag:
191
193
  return tag_end
192
194
  end
193
-
195
+
194
196
  # Maps a tag to a node instance by asking the current node to lookup the tag name. This function is called for each tag and thus heavily affects performance.
195
197
  # @return [Node] The node for the given tag.
196
198
  def lookup_tag(tag)
@@ -223,7 +225,7 @@ module Utopia
223
225
  def content
224
226
  @end_tags.last.content
225
227
  end
226
-
228
+
227
229
  def parent
228
230
  @end_tags[-2]
229
231
  end
@@ -244,7 +246,7 @@ module Utopia
244
246
 
245
247
  @tags = []
246
248
  end
247
-
249
+
248
250
  attr :parent
249
251
  attr :attributes
250
252
  attr :content
@@ -252,9 +254,9 @@ module Utopia
252
254
 
253
255
  # A list of all tags in order of rendering them, which have not been finished yet.
254
256
  attr :tags
255
-
257
+
256
258
  attr :deferred
257
-
259
+
258
260
  def defer(value = nil, &block)
259
261
  @deferred << block
260
262
 
@@ -264,7 +266,7 @@ module Utopia
264
266
  def [](key)
265
267
  @attributes[key]
266
268
  end
267
-
269
+
268
270
  def call(document)
269
271
  @content = @buffer
270
272
  @buffer = XRB::MarkupString.new.force_encoding(Encoding::UTF_8)
@@ -277,15 +279,15 @@ module Utopia
277
279
 
278
280
  return @buffer
279
281
  end
280
-
282
+
281
283
  def write(string)
282
284
  @buffer << string
283
285
  end
284
-
286
+
285
287
  def text(string)
286
288
  XRB::Markup.append(@buffer, string)
287
289
  end
288
-
290
+
289
291
  def tag_complete(tag)
290
292
  tag.write(@buffer)
291
293
  end
@@ -294,14 +296,14 @@ module Utopia
294
296
  def empty?
295
297
  @tags.empty?
296
298
  end
297
-
299
+
298
300
  def tag_begin(tag)
299
301
  # raise ArgumentError.new("tag_begin: #{tag} is tag.self_closed?") if tag.self_closed?
300
302
 
301
303
  @tags << tag
302
304
  tag.write_opening_tag(@buffer)
303
305
  end
304
-
306
+
305
307
  def tag_end(tag)
306
308
  raise UnbalancedTagError.new(tag) unless @tags.pop.name == tag.name
307
309
  tag.write_closing_tag(@buffer)
@@ -11,10 +11,9 @@ require "xrb/builder"
11
11
  require "xrb/strings"
12
12
 
13
13
  require_relative "../path"
14
- require_relative "../locale"
15
14
 
16
15
  module Utopia
17
- class Content
16
+ module Content
18
17
  # Represents a link to some content with associated metadata.
19
18
  class Link
20
19
  # @param kind [Symbol] the kind of link.
@@ -8,7 +8,7 @@ require_relative "link"
8
8
  require "concurrent/map"
9
9
 
10
10
  module Utopia
11
- class Content
11
+ module Content
12
12
  # The file extension for markup nodes on disk.
13
13
  XNODE_EXTENSION = ".xnode"
14
14
  INDEX = "index"
@@ -114,7 +114,7 @@ module Utopia
114
114
  def symbolize_keys(map)
115
115
  # Second level attributes should be symbolic:
116
116
  map.each do |key, info|
117
- map[key] = info.each_with_object({}) { |(k,v),result| result[k.to_sym] = v }
117
+ map[key] = info.each_with_object({}){|(k,v),result| result[k.to_sym] = v}
118
118
  end
119
119
 
120
120
  return map
@@ -9,7 +9,7 @@ require "xrb/strings"
9
9
  require "xrb/tag"
10
10
 
11
11
  module Utopia
12
- class Content
12
+ module Content
13
13
  Tag = XRB::Tag
14
14
 
15
15
  # A hash which forces all keys to be symbols and fails with KeyError when strings are used.
@@ -60,7 +60,7 @@ module Utopia
60
60
  @opening_tag = opening_tag
61
61
  @closing_tag = closing_tag
62
62
  end
63
-
63
+
64
64
  attr :buffer
65
65
  attr :opening_tag
66
66
  attr :closing_tag
@@ -108,15 +108,15 @@ module Utopia
108
108
  raise UnbalancedTagError.new(@buffer, tag)
109
109
  end
110
110
  end
111
-
111
+
112
112
  def open_tag_begin(name, offset)
113
113
  @current = ParsedTag.new(name, offset)
114
114
  end
115
-
115
+
116
116
  def attribute(key, value)
117
117
  @current.tag.attributes[key] = value
118
118
  end
119
-
119
+
120
120
  def open_tag_end(self_closing)
121
121
  if self_closing
122
122
  @current.tag.closed = true
@@ -128,7 +128,7 @@ module Utopia
128
128
 
129
129
  @current = nil
130
130
  end
131
-
131
+
132
132
  def close_tag(name, offset)
133
133
  @current = @stack.pop
134
134
  tag = @current.tag
@@ -143,19 +143,19 @@ module Utopia
143
143
  def doctype(string)
144
144
  @delegate.write(string)
145
145
  end
146
-
146
+
147
147
  def comment(string)
148
148
  @delegate.write(string)
149
149
  end
150
-
150
+
151
151
  def instruction(string)
152
152
  @delegate.write(string)
153
153
  end
154
-
154
+
155
155
  def cdata(string)
156
156
  @delegate.write(string[9..-4])
157
157
  end
158
-
158
+
159
159
  def text(string)
160
160
  @delegate.text(string)
161
161
  end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2009-2025, by Samuel Williams.
5
+
6
+ require_relative "../middleware"
7
+ require_relative "../localization"
8
+
9
+ require_relative "links"
10
+ require_relative "node"
11
+ require_relative "markup"
12
+ require_relative "tags"
13
+
14
+ require "xrb/template"
15
+ require "concurrent/map"
16
+ require "traces/provider"
17
+
18
+ module Utopia
19
+ module Content
20
+ # A middleware which serves dynamically generated content based on markup files.
21
+ class Middleware
22
+ CONTENT_NAMESPACE = "content".freeze
23
+ UTOPIA_NAMESPACE = "utopia".freeze
24
+ CONTENT_TAG_NAME = "utopia:content".freeze
25
+
26
+ # @param root [String] The content root where pages will be generated from.
27
+ # @param namespaces [Hash<String,Library>] Tag namespaces for dynamic tag lookup.
28
+ def initialize(app, root: Utopia::default_root, namespaces: {})
29
+ @app = app
30
+ @root = root
31
+
32
+ @template_cache = Concurrent::Map.new
33
+ @node_cache = Concurrent::Map.new
34
+
35
+ @links = Links.new(@root)
36
+
37
+ @namespaces = namespaces
38
+
39
+ # Default content namespace for dynamic path based lookup:
40
+ @namespaces[CONTENT_NAMESPACE] ||= self.method(:content_tag)
41
+
42
+ # The core namespace for utopia specific functionality:
43
+ @namespaces[UTOPIA_NAMESPACE] ||= Tags
44
+ end
45
+
46
+ def freeze
47
+ return self if frozen?
48
+
49
+ @root.freeze
50
+ @namespaces.values.each(&:freeze)
51
+ @namespaces.freeze
52
+
53
+ super
54
+ end
55
+
56
+ attr :root
57
+
58
+ # TODO we should remove this method and expose `@links` directly.
59
+ def links(path, **options)
60
+ @links.index(path, **options)
61
+ end
62
+
63
+ def fetch_template(path)
64
+ @template_cache.fetch_or_store(path.to_s) do
65
+ XRB::Template.load_file(path)
66
+ end
67
+ end
68
+
69
+ # Look up a named tag such as `<entry />` or `<content:page>...`
70
+ def lookup_tag(qualified_name, node)
71
+ namespace, name = XRB::Tag.split(qualified_name)
72
+
73
+ if library = @namespaces[namespace]
74
+ library.call(name, node)
75
+ end
76
+ end
77
+
78
+ # @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.
79
+ def lookup_node(path, locale = nil)
80
+ resolve_link(
81
+ @links.for(path, locale)
82
+ )
83
+ end
84
+
85
+ def resolve_link(link)
86
+ if full_path = link&.full_path(@root)
87
+ if File.exist?(full_path)
88
+ return Node.new(self, link.path, link.path, full_path)
89
+ end
90
+ end
91
+ end
92
+
93
+ def respond(link, request)
94
+ if node = resolve_link(link)
95
+ attributes = request.env.fetch(VARIABLES_KEY, {}).to_hash
96
+
97
+ return node.process!(request, attributes)
98
+ elsif redirect_uri = link[:uri]
99
+ return [307, {HTTP::LOCATION => redirect_uri}, []]
100
+ end
101
+ end
102
+
103
+ def call(env)
104
+ request = Rack::Request.new(env)
105
+ path = Path.create(request.path_info)
106
+
107
+ # Check if the request is to a non-specific index. This only works for requests with a given name:
108
+ basename = path.basename
109
+ directory_path = File.join(@root, path.dirname.components, basename)
110
+
111
+ # If the request for /foo/bar is actually a directory, rewrite it to /foo/bar/index:
112
+ if File.directory? directory_path
113
+ index_path = [basename, INDEX]
114
+
115
+ return [307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []]
116
+ end
117
+
118
+ locale = env[Localization::CURRENT_LOCALE_KEY]
119
+ if link = @links.for(path, locale)
120
+ if response = self.respond(link, request)
121
+ return response
122
+ end
123
+ end
124
+
125
+ return @app.call(env)
126
+ end
127
+
128
+ private
129
+
130
+ def lookup_content(name, parent_path)
131
+ if String === name && name.index("/")
132
+ name = Path.create(name)
133
+ end
134
+
135
+ if Path === name
136
+ name = parent_path + name
137
+ name_path = name.components.dup
138
+ name_path[-1] += XNODE_EXTENSION
139
+ else
140
+ name_path = name + XNODE_EXTENSION
141
+ end
142
+
143
+ components = parent_path.components.dup
144
+
145
+ while components.any?
146
+ tag_path = File.join(@root, components, name_path)
147
+
148
+ if File.exist? tag_path
149
+ return Node.new(self, Path[components] + name, parent_path + name, tag_path)
150
+ end
151
+
152
+ if String === name_path
153
+ tag_path = File.join(@root, components, "_" + name_path)
154
+
155
+ if File.exist? tag_path
156
+ return Node.new(self, Path[components] + name, parent_path + name, tag_path)
157
+ end
158
+ end
159
+
160
+ components.pop
161
+ end
162
+
163
+ return nil
164
+ end
165
+
166
+ def content_tag(name, node)
167
+ full_path = node.parent_path + name
168
+
169
+ name = full_path.pop
170
+
171
+ # If the current node is called 'foo', we can't lookup 'foo' in the current directory or we will have infinite recursion.
172
+ while full_path.last == name
173
+ full_path.pop
174
+ end
175
+
176
+ cache_key = full_path + name
177
+
178
+ @node_cache.fetch_or_store(cache_key) do
179
+ lookup_content(name, full_path)
180
+ end
181
+ end
182
+ end
183
+
184
+ Traces::Provider(Middleware) do
185
+ def respond(link, request)
186
+ attributes = {
187
+ "link.key" => link.key,
188
+ "link.href" => link.href
189
+ }
190
+
191
+ Traces.trace("utopia.content.middleware.respond", attributes: attributes) {super}
192
+ end
193
+ end
194
+ end
195
+ end
@@ -4,7 +4,7 @@
4
4
  # Copyright, 2017-2022, by Samuel Williams.
5
5
 
6
6
  module Utopia
7
- class Content
7
+ module Content
8
8
  # A namespace which contains tags which can be rendered within a {Document}.
9
9
  module Namespace
10
10
  def self.extended(other)
@@ -12,7 +12,7 @@ require_relative "document"
12
12
  require "pathname"
13
13
 
14
14
  module Utopia
15
- class Content
15
+ module Content
16
16
  # Represents an immutable node within the content hierarchy.
17
17
  class Node
18
18
  def initialize(controller, uri_path, request_path, file_path)
@@ -4,7 +4,7 @@
4
4
  # Copyright, 2010-2025, by Samuel Williams.
5
5
 
6
6
  module Utopia
7
- class Content
7
+ module Content
8
8
  # Compatibility with older versions of rack:
9
9
  EXPIRES = "expires".freeze
10
10
  CACHE_CONTROL = "cache-control".freeze
@@ -8,7 +8,7 @@ require_relative "namespace"
8
8
  require "variant"
9
9
 
10
10
  module Utopia
11
- class Content
11
+ module Content
12
12
  # Tags which provide intrinsic behaviour within the content middleware.
13
13
  module Tags
14
14
  extend Namespace