diff --git a/lib/utopia/content/builder.rb b/lib/utopia/content/builder.rb index f5e797b7..1642a3c6 100644 --- a/lib/utopia/content/builder.rb +++ b/lib/utopia/content/builder.rb @@ -82,11 +82,7 @@ def write(string) def text(content) return unless content - if content.respond_to?(:build_markup) - content.build_markup(self) - else - XRB::Markup.append(@output, content) - end + content.build_markup(self) end # Write a complete tag to the output. diff --git a/lib/utopia/content/links.rb b/lib/utopia/content/links.rb index 33215ca4..e803efd4 100644 --- a/lib/utopia/content/links.rb +++ b/lib/utopia/content/links.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2015-2025, by Samuel Williams. +# Copyright, 2015-2026, by Samuel Williams. require_relative "link" @@ -204,8 +204,10 @@ def indices def each(locale) return to_enum(:each, locale) unless block_given? - ordered.each do |links| - yield links.find{|link| link.locale == locale} + @named.each_key do |name| + if link = lookup(name, locale) + yield link + end end end diff --git a/lib/utopia/content/markup.rb b/lib/utopia/content/markup.rb index 826fc9c5..79733e43 100644 --- a/lib/utopia/content/markup.rb +++ b/lib/utopia/content/markup.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2009-2025, by Samuel Williams. +# Copyright, 2009-2026, by Samuel Williams. require "xrb/parsers" require "xrb/entities" @@ -108,9 +108,9 @@ def end_location # @returns [String] The resulting string. def to_s if @closing_tag - "#{start_location}: #{@opening_tag} was not closed!" - else "#{start_location}: #{@opening_tag} was closed by #{@closing_tag}!" + else + "#{start_location}: #{@opening_tag} was not closed!" end end end diff --git a/lib/utopia/content/middleware.rb b/lib/utopia/content/middleware.rb index 856fc3b8..2fee3fb1 100644 --- a/lib/utopia/content/middleware.rb +++ b/lib/utopia/content/middleware.rb @@ -159,12 +159,7 @@ def call(request) private def lookup_content(name, parent_path) - if String === name && name.index("/") - name = Path.create(name) - end - if Path === name - name = parent_path + name name_path = name.components.dup name_path[-1] += XNODE_EXTENSION else @@ -195,6 +190,21 @@ def lookup_content(name, parent_path) end def content_tag(name, node, parent_path: node.parent_path) + # Preserve nested names while searching the physical content hierarchy: + if String === name + if name.index("/") + name = Path.create(name) + end + end + + if Path === name + cache_key = parent_path + name + + return @node_cache.fetch_or_store(cache_key) do + lookup_content(name, parent_path) + end + end + full_path = parent_path + name name = full_path.pop diff --git a/test/utopia/content.rb b/test/utopia/content.rb index dfe9f488..19c7a53b 100755 --- a/test/utopia/content.rb +++ b/test/utopia/content.rb @@ -115,6 +115,12 @@ expect(last_response.status).to be == 307 expect(last_response.headers["location"]).to be == "foo" end + + it "passes missing content to the downstream middleware" do + client.get "/missing" + + expect(last_response.status).to be == 404 + end end describe Utopia::Content do diff --git a/test/utopia/content/builder.rb b/test/utopia/content/builder.rb new file mode 100644 index 00000000..c470762f --- /dev/null +++ b/test/utopia/content/builder.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "utopia/content/builder" + +describe Utopia::Content::Builder do + it "parses captured content for non-callable nodes" do + builder = subject.new(nil, nil, Object.new, {}) + markup = nil + document = Object.new + document.define_singleton_method(:parse_markup) do |content| + markup = content + end + + builder.write("
Hello
") + builder.call(document) + + expect(markup).to be == "Hello
" + end + + it "escapes plain text" do + builder = subject.new(nil, nil, Object.new, {}) + content = Object.new + content.define_singleton_method(:to_s){"Cats & Dogs"} + + builder.text(content) + + expect(builder.to_s).to be == "Cats & Dogs" + end +end diff --git a/test/utopia/content/document.rb b/test/utopia/content/document.rb index bf62fe94..0c9a7e66 100644 --- a/test/utopia/content/document.rb +++ b/test/utopia/content/document.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2017-2025, by Samuel Williams. +# Copyright, 2017-2026, by Samuel Williams. require "utopia/content/document" require "utopia/request" @@ -16,6 +16,17 @@ expect(document.request.delegate).to be == request.delegate end + it "exposes document attributes and request context" do + variables = Object.new + localization = Object.new + request.variables = variables + document = subject.new(request, {title: "Hello"}, localization: localization) + + expect(document[:title]).to be == "Hello" + expect(document.controller).to be_equal(variables) + expect(document.localization).to be_equal(localization) + end + it "uses the original request path" do request.path = "/rewritten" @@ -69,6 +80,24 @@ expect(document.base_uri(relative_to)).to be == Utopia::Path[""] end + it "generates a base uri from the current node" do + node = Struct.new(:uri_path) do + def call(document, state) + document.text(document.base_uri.to_s) + end + end.new(Utopia::Path["/page"]) + + expect(document.render_node(node)).to be == "" + end + + it "exposes captured content while rendering" do + node = proc do |document, state| + document.text(document.content) + end + + expect(document.render_node(node)).to be == "" + end + with "nested request path" do let(:path) {"/nested/index"} diff --git a/test/utopia/content/links.rb b/test/utopia/content/links.rb index 49d9cdff..96e59758 100644 --- a/test/utopia/content/links.rb +++ b/test/utopia/content/links.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2015-2025, by Samuel Williams. +# Copyright, 2015-2026, by Samuel Williams. require "utopia/content/links" @@ -9,6 +9,15 @@ let(:root) {File.expand_path("links", __dir__)} let(:links) {subject.new(root)} + it "provides uncached convenience methods" do + mock(subject) do |mock| + mock.replace(:warn){} + end + + expect(subject.for(root, Utopia::Path["/index"]).title).to be == "Home" + expect(subject.index(root, Utopia::Path["/"], name: "welcome").size).to be == 1 + end + with "#index_filter" do it "should match index" do expect(links.index_filter).to be =~ "index.xnode" @@ -81,6 +90,13 @@ expect(matched.collect(&:title)).to be == ["One", "Two", "Three", "四"] end + + it "merges default metadata into localized indexes" do + matched = links.index("/", locale: "en") + four = matched.find{|link| link.name == "four"} + + expect(four.info[:category]).to be == "examples" + end end with "#index" do @@ -213,6 +229,31 @@ link = links.for(Utopia::Path["/bar/index"]) expect(link.title).to be == "Bar" end + + it "falls back to an unlocalized link" do + link = links.for(Utopia::Path["/welcome"], "en") + + expect(link.title).to be == "Welcome" + expect(links.for(Utopia::Path["/welcome"], "en", fallback: false)).to be_nil + end + end + + it "handles missing content directories" do + resolver = links.links(Utopia::Path["/missing"]) + + expect(resolver.ordered).to be(:empty?) + expect(resolver.lookup("missing")).to be_nil + end + + it "enumerates one localized link per name" do + resolver = links.links(Utopia::Path.root) + selected = resolver.each("en").to_a + + expect(selected).not.to be(:empty?) + expect(selected).to be(:all?) do |link| + link.locale == "en" || link.locale.nil? + end + expect(selected.map(&:name)).to be(:include?, "welcome") end it "encodes inferred link paths" do @@ -241,6 +282,23 @@ expect(link.href).to be == "/articles/document" end + it "describes link metadata" do + link = Utopia::Content::Link.new( + :virtual, + "document", + nil, + Utopia::Path.new(["", "articles", "document"]), + {title: "Document"}, + ) + copy = Utopia::Content::Link.new(:virtual, "document", nil, link.path, link.info) + + expect(link).to be(:virtual?) + expect(link).to be(:default_locale?) + expect(link.to_s).to be =~ /Document/ + expect(link).to be(:eql?, copy) + expect(link).not.to be(:eql?, Object.new) + end + it "keeps explicit targets opaque until relativization" do target = "some target" link = Utopia::Content::Link.new(:virtual, "target", nil, nil, {href: target}) diff --git a/test/utopia/content/localized/four/links.yaml b/test/utopia/content/localized/four/links.yaml index 6dfa7988..dc115c41 100644 --- a/test/utopia/content/localized/four/links.yaml +++ b/test/utopia/content/localized/four/links.yaml @@ -1,4 +1,6 @@ +index: + category: examples index.en: title: "Four" index.zh: - title: "四" \ No newline at end of file + title: "四" diff --git a/test/utopia/content/markup.rb b/test/utopia/content/markup.rb index c83d5d51..ccb359c5 100644 --- a/test/utopia/content/markup.rb +++ b/test/utopia/content/markup.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2015-2025, by Samuel Williams. +# Copyright, 2015-2026, by Samuel Williams. require "utopia/content/markup" @@ -18,6 +18,24 @@ def method_missing(*arguments) end describe Utopia::Content::MarkupParser do + it "normalizes symbolic hash membership" do + attributes = Utopia::Content::SymbolicHash.new + attributes[:title] = "Hello" + + expect(attributes.fetch("title")).to be == "Hello" + expect(attributes).to be(:include?, :title) + expect(attributes).to be(:include?, "title") + end + + it "describes parsed tags" do + tag = subject::ParsedTag.new("section", 0) + + expect(tag.to_s).to be == "Foobar}}.to raise_exception(Utopia::Content::MarkupParser::UnbalancedTagError) + error = begin + parse %Q{
Foobar} + rescue subject::UnbalancedTagError => error + error + end + + expect(error).to be_a(subject::UnbalancedTagError) + expect(error.start_location.to_s).to be == "[1:1]" + expect(error.end_location.to_s).to be == "[1:11]" + expect(error.to_s).to be =~ /
was closed by
Foobar}}.to raise_exception(Utopia::Content::MarkupParser::UnbalancedTagError) + error = begin + parse %Q{
Foobar} + rescue subject::UnbalancedTagError => error + error + end + + expect(error).to be_a(subject::UnbalancedTagError) + expect(error.end_location).to be_nil + expect(error.to_s).to be =~ /
was not closed/
end
end
diff --git a/test/utopia/content/node.rb b/test/utopia/content/node.rb
index e61872c6..f2800026 100644
--- a/test/utopia/content/node.rb
+++ b/test/utopia/content/node.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Released under the MIT License.
-# Copyright, 2015-2025, by Samuel Williams.
+# Copyright, 2015-2026, by Samuel Williams.
require "utopia/content"
@@ -29,6 +29,18 @@
expect(links[1].name).to be == "second"
end
+ it "should enumerate links" do
+ node = content.lookup_node(Utopia::Path["/ordered/index"])
+ names = []
+
+ result = node.links do |link|
+ names << link.name
+ end
+
+ expect(names).to be == ["first", "second"]
+ expect(result).to be == node.links
+ end
+
it "should list related links" do
node = content.lookup_node(Utopia::Path["/related/foo"], "en")
@@ -66,6 +78,12 @@
expect(node.local_path("preview.jpg")).to be == (base + "ordered/preview.jpg")
end
+
+ it "can compute absolute paths" do
+ node = content.lookup_node(Utopia::Path["/ordered/index"])
+
+ expect(node.local_path("/shared/preview.jpg")).to be == (base + "shared/preview.jpg")
+ end
end
with "#relative_path" do
@@ -81,4 +99,62 @@
expect(node.relative_path("preview.jpg")).to be == (Utopia::Path["/ordered/preview.jpg"])
end
end
+
+ it "exposes its name" do
+ node = content.lookup_node(Utopia::Path["/ordered/first"])
+
+ expect(node.name).to be == "first"
+ end
+
+ it "uses the containing directory for index siblings" do
+ node = content.lookup_node(Utopia::Path["/ordered/index"])
+
+ expect(node.siblings_path).to be == Utopia::Path.root
+ end
+
+ it "exposes rendering context" do
+ deferred = []
+ linked = []
+
+ node = Object.new
+ node.define_singleton_method(:links) do |*arguments, **options, &block|
+ block&.call(:link)
+ linked << [arguments, options]
+ return :links
+ end
+
+ state = Struct.new(:node, :attributes) do
+ define_method(:defer) do |&block|
+ deferred << block
+ return :deferred
+ end
+ end.new(node, {local: "Local"})
+
+ document = Struct.new(:controller, :localization, :request, :attributes, :content, :parent, :first).new(
+ :controller,
+ :localization,
+ :request,
+ {global: "Global"},
+ :content,
+ :parent,
+ :first,
+ )
+ context = subject::Context.new(document, state)
+
+ expect(context.partial{:content}).to be == :deferred
+ expect(deferred.first.call).to be == :content
+ expect(context.controller).to be == :controller
+ expect(context.localization).to be == :localization
+ expect(context.request).to be == :request
+ expect(context.response).to be_equal(document)
+ expect(context.attributes).to be_equal(state.attributes)
+ expect(context[:local]).to be == "Local"
+ expect(context[:global]).to be == "Global"
+ expect(context.current).to be_equal(state)
+ expect(context.content).to be == :content
+ expect(context.parent).to be == :parent
+ expect(context.first).to be == :first
+ expect(context.links(".", locale: "en"){|link| link}).to be == :links
+ expect(linked).to be == [[["."], {locale: "en"}]]
+ end
end
diff --git a/test/utopia/content/relative.rb b/test/utopia/content/relative.rb
index f5eba12d..d1ea4cda 100644
--- a/test/utopia/content/relative.rb
+++ b/test/utopia/content/relative.rb
@@ -22,4 +22,27 @@
expect(markup).to be == "