From a322fc78d960f40b6d0ff0688f0e5f738d5602db Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 17 Aug 2026 11:28:56 +1200 Subject: [PATCH 1/2] Cover content rendering contracts. Assisted-By: devx/cbdeae41-9308-4071-ad53-58cd92db2946 --- lib/utopia/content/builder.rb | 6 +- lib/utopia/content/links.rb | 8 +- lib/utopia/content/markup.rb | 6 +- lib/utopia/content/middleware.rb | 20 +++-- test/utopia/content.rb | 6 ++ test/utopia/content/builder.rb | 32 ++++++++ test/utopia/content/document.rb | 31 +++++++- test/utopia/content/links.rb | 60 +++++++++++++- test/utopia/content/localized/four/links.yaml | 4 +- test/utopia/content/markup.rb | 50 +++++++++++- test/utopia/content/node.rb | 78 ++++++++++++++++++- test/utopia/content/relative.rb | 23 ++++++ test/utopia/content/relative/_fallback.xnode | 1 + .../content/relative/shared/frame.xnode | 1 + test/utopia/content/response.rb | 10 ++- test/utopia/content/tags.rb | 12 ++- 16 files changed, 323 insertions(+), 25 deletions(-) create mode 100644 test/utopia/content/builder.rb create mode 100644 test/utopia/content/relative/_fallback.xnode create mode 100644 test/utopia/content/relative/shared/frame.xnode 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..29e48ff4 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){} + + expect(subject.for(root, Utopia::Path["/index"]).title).to be == "Home" + expect(subject.index(root, Utopia::Path["/"], name: "welcome").size).to be == 1 + end + 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 == "
" + + tag.tag.attributes[:class] = "content" + expect(tag.to_s).to be == "
" + end + it "should format open tags correctly" do foo_tag = Utopia::Content::Tag.opened("foo", bar: true, baz: "bob") @@ -65,11 +83,37 @@ def parse(string) expect(delegate.events).to be == expected_events end + it "should parse processing instructions" do + delegate = TestDelegate.new + parser = subject.new(XRB::Buffer.new(""), delegate) + + parser.instruction("") + + expect(delegate.events).to be == [[:write, ""]] + end + it "should fail with incorrect closing tag" do - expect{parse %Q{

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

/ end it "should fail with unclosed tag" do - expect{parse %Q{

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 == "Hello World" end + + it "resolves nested content paths through the physical hierarchy" do + parent = content.lookup_node(Utopia::Path["/fallback/index"]) + node = content.lookup_tag("content:shared/frame", parent) + + expect(node.uri_path).to be == Utopia::Path["/shared/frame"] + expect(node.request_path).to be == Utopia::Path["/fallback/shared/frame"] + expect(node.process!(nil).read).to be == "Nested Frame\n" + end + + it "returns nil when nested content does not exist" do + parent = content.lookup_node(Utopia::Path["/fallback/index"]) + + expect(content.lookup_tag("content:missing/absent", parent)).to be_nil + end + + it "avoids resolving the current name recursively" do + parent = content.lookup_node(Utopia::Path["/fallback/index"]) + node = content.lookup_tag("content:fallback", parent) + + expect(node.uri_path).to be == Utopia::Path["/fallback"] + expect(node.process!(nil).read).to be == "Fallback\n" + end end diff --git a/test/utopia/content/relative/_fallback.xnode b/test/utopia/content/relative/_fallback.xnode new file mode 100644 index 00000000..ab984305 --- /dev/null +++ b/test/utopia/content/relative/_fallback.xnode @@ -0,0 +1 @@ +Fallback diff --git a/test/utopia/content/relative/shared/frame.xnode b/test/utopia/content/relative/shared/frame.xnode new file mode 100644 index 00000000..c6c5d2ab --- /dev/null +++ b/test/utopia/content/relative/shared/frame.xnode @@ -0,0 +1 @@ +Nested Frame diff --git a/test/utopia/content/response.rb b/test/utopia/content/response.rb index 8dc6e692..22831286 100755 --- a/test/utopia/content/response.rb +++ b/test/utopia/content/response.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2012-2025, by Samuel Williams. +# Copyright, 2012-2026, by Samuel Williams. require "utopia/content/response" @@ -34,4 +34,12 @@ expect(response.headers["content-type"]).to be == "text/html" end + + it "joins rendered content" do + response.body << "Hello" + response.body << " World" + + expect(response.content).to be == "Hello World" + expect(response.lookup(nil)).to be_nil + end end diff --git a/test/utopia/content/tags.rb b/test/utopia/content/tags.rb index f310eac4..d90cf42d 100644 --- a/test/utopia/content/tags.rb +++ b/test/utopia/content/tags.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/tags" require "utopia/content/document" @@ -53,6 +53,16 @@ def lookup_tag(tag) expect(result).to be == "Goodbye World" end + + it "uses the configured environment by default" do + mock(Variant) do |mock| + mock.replace(:for){|name| :testing} + + result = document.render_node(node) + + expect(result).to be == "Hello World" + end + end end with content: "" do From c8d63636a47ae3f1867c773dd155bba73857c8fa Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 17 Aug 2026 12:39:23 +1200 Subject: [PATCH 2/2] Clarify mock test structure. Assisted-By: devx/cbdeae41-9308-4071-ad53-58cd92db2946 --- test/utopia/content/links.rb | 6 +++--- test/utopia/content/tags.rb | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test/utopia/content/links.rb b/test/utopia/content/links.rb index 29e48ff4..96e59758 100644 --- a/test/utopia/content/links.rb +++ b/test/utopia/content/links.rb @@ -12,10 +12,10 @@ it "provides uncached convenience methods" do mock(subject) do |mock| mock.replace(:warn){} - - expect(subject.for(root, Utopia::Path["/index"]).title).to be == "Home" - expect(subject.index(root, Utopia::Path["/"], name: "welcome").size).to be == 1 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 diff --git a/test/utopia/content/tags.rb b/test/utopia/content/tags.rb index d90cf42d..bf0b8aa7 100644 --- a/test/utopia/content/tags.rb +++ b/test/utopia/content/tags.rb @@ -57,11 +57,11 @@ def lookup_tag(tag) it "uses the configured environment by default" do mock(Variant) do |mock| mock.replace(:for){|name| :testing} - - result = document.render_node(node) - - expect(result).to be == "Hello World" end + + result = document.render_node(node) + + expect(result).to be == "Hello World" end end