Skip to content

Commit 7744577

Browse files
committed
Refactor Document::State -> XRB::Builder.
1 parent fb71bb8 commit 7744577

File tree

3 files changed

+210
-84
lines changed

3 files changed

+210
-84
lines changed

lib/utopia/content/builder.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "xrb/builder"
7+
8+
module Utopia
9+
module Content
10+
DEFERRED_TAG_NAME = "utopia:deferred".freeze
11+
12+
# A builder for rendering Utopia content that extends XRB::Builder with Utopia-specific functionality.
13+
class Builder < XRB::Builder
14+
def initialize(parent, tag, node, attributes = tag.to_hash, **options)
15+
super(**options)
16+
17+
@parent = parent
18+
@tag = tag
19+
@node = node
20+
@attributes = attributes
21+
22+
@content = nil
23+
@deferred = []
24+
@tags = []
25+
end
26+
27+
attr :parent
28+
attr :tag
29+
attr :attributes
30+
attr :content
31+
attr :node
32+
33+
# A list of all tags in order of rendering them, which have not been finished yet.
34+
attr :tags
35+
36+
attr :deferred
37+
38+
def defer(value = nil, &block)
39+
@deferred << block
40+
41+
XRB::Tag.closed(DEFERRED_TAG_NAME, :id => @deferred.size - 1)
42+
end
43+
44+
def [](key)
45+
@attributes[key]
46+
end
47+
48+
def call(document)
49+
@content = @output.dup
50+
@output.clear
51+
52+
if node.respond_to? :call
53+
node.call(document, self)
54+
else
55+
document.parse_markup(@content)
56+
end
57+
58+
return @output
59+
end
60+
61+
# Override write to directly append to output
62+
def write(string)
63+
@output << string
64+
end
65+
66+
# Override text to handle build_markup protocol
67+
def text(content)
68+
return unless content
69+
70+
if content.respond_to?(:build_markup)
71+
content.build_markup(self)
72+
else
73+
XRB::Markup.append(@output, content)
74+
end
75+
end
76+
77+
def tag_complete(tag)
78+
tag.write(@output)
79+
end
80+
81+
# Whether this state has any nested tags.
82+
def empty?
83+
@tags.empty?
84+
end
85+
86+
def tag_begin(tag)
87+
@tags << tag
88+
tag.write_opening_tag(@output)
89+
end
90+
91+
def tag_end(tag)
92+
raise UnbalancedTagError.new(tag) unless @tags.pop.name == tag.name
93+
tag.write_closing_tag(@output)
94+
end
95+
end
96+
end
97+
end

lib/utopia/content/document.rb

Lines changed: 4 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66
require_relative "links"
77
require_relative "response"
88
require_relative "markup"
9+
require_relative "builder"
910

1011
module Utopia
1112
module Content
12-
DEFERRED_TAG_NAME = "utopia:deferred".freeze
13-
1413
# This error is raised if a tag doesn't match up when parsing.
1514
class UnbalancedTagError < StandardError
1615
def initialize(tag)
@@ -135,9 +134,9 @@ def tag_begin(tag, node = nil)
135134
node ||= lookup_tag(tag)
136135

137136
if node
138-
@current = State.new(@current, tag, node)
137+
@current = Builder.new(@current, tag, node, tag.to_hash, indent: false)
139138

140-
node.tag_begin(self, state) if node.respond_to?(:tag_begin)
139+
node.tag_begin(self, @current) if node.respond_to?(:tag_begin)
141140

142141
return node
143142
end
@@ -184,7 +183,7 @@ def tag_end(tag = nil)
184183
end
185184

186185
def render_node(node, attributes = {})
187-
@current = State.new(@current, nil, node, attributes)
186+
@current = Builder.new(@current, nil, node, attributes, indent: false)
188187

189188
# We keep track of the first thing rendered by this document.
190189
@first ||= @current
@@ -230,84 +229,5 @@ def parent
230229
@end_tags[-2]
231230
end
232231
end
233-
234-
# The state of a single tag being rendered within a document instance.
235-
class Document::State
236-
def initialize(parent, tag, node, attributes = tag.to_hash)
237-
@parent = parent
238-
@tag = tag
239-
@node = node
240-
@attributes = attributes
241-
242-
@buffer = XRB::MarkupString.new.force_encoding(Encoding::UTF_8)
243-
@content = nil
244-
245-
@deferred = []
246-
247-
@tags = []
248-
end
249-
250-
attr :parent
251-
attr :attributes
252-
attr :content
253-
attr :node
254-
255-
# A list of all tags in order of rendering them, which have not been finished yet.
256-
attr :tags
257-
258-
attr :deferred
259-
260-
def defer(value = nil, &block)
261-
@deferred << block
262-
263-
Tag.closed(DEFERRED_TAG_NAME, :id => @deferred.size - 1)
264-
end
265-
266-
def [](key)
267-
@attributes[key]
268-
end
269-
270-
def call(document)
271-
@content = @buffer
272-
@buffer = XRB::MarkupString.new.force_encoding(Encoding::UTF_8)
273-
274-
if node.respond_to? :call
275-
node.call(document, self)
276-
else
277-
document.parse_markup(@content)
278-
end
279-
280-
return @buffer
281-
end
282-
283-
def write(string)
284-
@buffer << string
285-
end
286-
287-
def text(string)
288-
XRB::Markup.append(@buffer, string)
289-
end
290-
291-
def tag_complete(tag)
292-
tag.write(@buffer)
293-
end
294-
295-
# Whether this state has any nested tags.
296-
def empty?
297-
@tags.empty?
298-
end
299-
300-
def tag_begin(tag)
301-
# raise ArgumentError.new("tag_begin: #{tag} is tag.self_closed?") if tag.self_closed?
302-
303-
@tags << tag
304-
tag.write_opening_tag(@buffer)
305-
end
306-
307-
def tag_end(tag)
308-
raise UnbalancedTagError.new(tag) unless @tags.pop.name == tag.name
309-
tag.write_closing_tag(@buffer)
310-
end
311-
end
312232
end
313233
end

test/utopia/document/builder.rb

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require 'utopia/content/builder'
7+
require 'utopia/content/tags'
8+
require 'xrb/builder'
9+
10+
describe Utopia::Content::Builder do
11+
it "should inherit from XRB::Builder" do
12+
expect(Utopia::Content::Builder.superclass).to be == XRB::Builder
13+
end
14+
15+
it "should accept positional arguments" do
16+
builder = Utopia::Content::Builder.new(nil, nil, nil, {})
17+
18+
expect(builder.parent).to be_nil
19+
expect(builder.tag).to be_nil
20+
expect(builder.node).to be_nil
21+
expect(builder.attributes).to be == {}
22+
end
23+
24+
it "should default attributes to tag.to_hash" do
25+
tag = XRB::Tag.new('div', false, {'id' => 'test'})
26+
27+
builder = Utopia::Content::Builder.new(nil, tag, nil)
28+
29+
expect(builder.attributes).to be == {'id' => 'test'}
30+
end
31+
32+
it "should support fragment rendering via build_markup protocol" do
33+
builder = Utopia::Content::Builder.new(nil, nil, nil, {})
34+
35+
fragment = XRB::Builder.fragment do |builder|
36+
builder.inline('p') do
37+
builder.text "Hello from fragment"
38+
end
39+
end
40+
41+
builder.text(fragment)
42+
43+
expect(builder.output).to be =~ /Hello from fragment/
44+
expect(builder.output).not.to be =~ /&lt;/
45+
end
46+
47+
it "should track parent builders" do
48+
parent = Utopia::Content::Builder.new(nil, nil, nil, {})
49+
child = Utopia::Content::Builder.new(parent, nil, nil, {})
50+
51+
expect(child.parent).to be == parent
52+
end
53+
54+
it "should track tags" do
55+
builder = Utopia::Content::Builder.new(nil, nil, nil, {})
56+
57+
expect(builder.tags).to be == []
58+
expect(builder).to be(:empty?)
59+
60+
tag = XRB::Tag.new('div', false, {})
61+
builder.tag_begin(tag)
62+
63+
expect(builder.tags).to be == [tag]
64+
expect(builder).not.to be(:empty?)
65+
end
66+
67+
it "should support deferred content" do
68+
builder = Utopia::Content::Builder.new(nil, nil, nil, {})
69+
70+
expect(builder.deferred).to be == []
71+
72+
deferred_tag = builder.defer { "deferred content" }
73+
74+
expect(builder.deferred.size).to be == 1
75+
expect(deferred_tag.name).to be == "utopia:deferred"
76+
expect(deferred_tag.attributes[:id]).to be == 0
77+
end
78+
79+
it "should write text content" do
80+
builder = Utopia::Content::Builder.new(nil, nil, nil, {})
81+
82+
builder.write("Hello ")
83+
builder.write("World")
84+
85+
expect(builder.output).to be == "Hello World"
86+
end
87+
88+
it "should escape regular text via text method" do
89+
builder = Utopia::Content::Builder.new(nil, nil, nil, {})
90+
91+
builder.text("<script>alert('xss')</script>")
92+
93+
expect(builder.output).to be =~ /&lt;script&gt;/
94+
expect(builder.output).not.to be =~ /<script>/
95+
end
96+
97+
it "should not escape objects with build_markup protocol" do
98+
builder = Utopia::Content::Builder.new(nil, nil, nil, {})
99+
100+
# Use XRB::Builder.fragment which implements build_markup
101+
fragment = XRB::Builder.fragment do |b|
102+
b.inline('p') { b.text "Safe HTML" }
103+
end
104+
105+
builder.text(fragment)
106+
107+
expect(builder.output).to be =~ /<p>Safe HTML<\/p>/
108+
end
109+
end

0 commit comments

Comments
 (0)