/
sgml.rb
429 lines (358 loc) · 11.8 KB
/
sgml.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
# frozen_string_literal: true
if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.0")
using Phlex::Overrides::Symbol::Name
end
module Phlex
# **Standard Generalized Markup Language** for behaviour common to {HTML} and {SVG}.
class SGML
class << self
# Render the view to a String. Arguments are delegated to {.new}.
def call(...)
new(...).call
end
# Create a new instance of the component.
# @note The block will not be delegated {#initialize}. Instead, it will be sent to {#template} when rendering.
def new(*args, **kwargs, &block)
if block
object = super(*args, **kwargs, &nil)
object.instance_variable_set(:@_content_block, block)
object
else
super
end
end
# @api private
def rendered_at_least_once!
alias_method :__attributes__, :__final_attributes__
alias_method :call, :__final_call__
end
# @api private
def element_method?(method_name)
return false unless instance_methods.include?(method_name)
owner = instance_method(method_name).owner
return true if owner.is_a?(Phlex::Elements) && owner.registered_elements[method_name]
false
end
end
# @!method initialize
# @abstract Override to define an initializer for your component.
# @note Your initializer will not receive a block passed to {.new}. Instead, this block will be sent to {#template} when rendering.
# @example
# def initialize(articles:)
# @articles = articles
# end
# @abstract Override to define a template for your component.
# @example
# def template
# h1 { "👋 Hello World!" }
# end
# @example Your template may yield a content block.
# def template
# main {
# h1 { "Hello World" }
# yield
# }
# end
# @example Alternatively, you can delegate the content block to an element.
# def template(&block)
# article(class: "card", &block)
# end
def template
yield
end
# @api private
def await(task)
if task.is_a?(Concurrent::IVar)
flush if task.pending?
task.wait.value
elsif defined?(Async::Task) && task.is_a?(Async::Task)
flush if task.running?
task.wait
else
raise ArgumentError, "Expected an asynchronous task / promise."
end
end
# Renders the view and returns the buffer. The default buffer is a mutable String.
def call(buffer = +"", context: Phlex::Context.new, view_context: nil, parent: nil, &block)
__final_call__(buffer, context: context, view_context: view_context, parent: parent, &block).tap do
self.class.rendered_at_least_once!
end
end
# @api private
def __final_call__(buffer = +"", context: Phlex::Context.new, view_context: nil, parent: nil, &block)
@_buffer = buffer
@_context = context
@_view_context = view_context
@_parent = parent
block ||= @_content_block
return "" unless render?
around_template do
if block
if is_a?(DeferredRender)
__vanish__(self, &block)
template
else
template do |*args|
if args.length > 0
yield_content_with_args(*args, &block)
else
yield_content(&block)
end
end
end
else
template
end
end
buffer << context.target unless parent
end
# Output text content. The text will be HTML-escaped.
# @param content [String, Symbol, Integer, void] the content to be output on the buffer. Strings, Symbols, and Integers are handled by `plain` directly, but any object can be handled by overriding `format_object`
# @return [nil]
# @see #format_object
def plain(content)
unless __text__(content)
raise ArgumentError, "You've passed an object to plain that is not handled by format_object. See https://rubydoc.info/gems/phlex/Phlex/SGML#format_object-instance_method for more information"
end
nil
end
# Output a whitespace character. This is useful for getting inline elements to wrap. If you pass a block, a whitespace will be output before and after yielding the block.
# @return [nil]
# @yield If a block is given, it yields the block with no arguments.
def whitespace(&block)
target = @_context.target
target << " "
if block_given?
yield_content(&block)
target << " "
end
nil
end
# Output an HTML comment.
# @return [nil]
def comment(&block)
target = @_context.target
target << "<!-- "
yield_content(&block)
target << " -->"
nil
end
# This method is very dangerous and should usually be avoided. It will output the given String without any HTML safety. You should never use this method to output unsafe user input.
# @param content [String|nil]
# @return [nil]
def unsafe_raw(content = nil)
return nil unless content
@_context.target << content
nil
end
# Capture a block of output as a String.
# @note This only works if the block's receiver is the current component or the block returns a String.
# @return [String]
def capture(&block)
return "" unless block
@_context.capturing_into(+"") { yield_content(&block) }
end
private
# @api private
def flush
return if @_context.capturing
target = @_context.target
@_buffer << target.dup
target.clear
end
# Render another component, block or enumerable
# @return [nil]
# @overload render(component, &block)
# Renders the component.
# @param component [Phlex::SGML]
# @overload render(component_class, &block)
# Renders a new instance of the component class. This is useful for component classes that take no arguments.
# @param component_class [Class<Phlex::SGML>]
# @overload render(proc)
# Renders the proc with {#yield_content}.
# @param proc [Proc]
# @overload render(enumerable)
# Renders each item of the enumerable.
# @param enumerable [Enumerable]
# @example
# render @items
def render(renderable, &block)
case renderable
when Phlex::SGML
renderable.call(@_buffer, context: @_context, view_context: @_view_context, parent: self, &block)
when Class
if renderable < Phlex::SGML
renderable.new.call(@_buffer, context: @_context, view_context: @_view_context, parent: self, &block)
end
when Enumerable
renderable.each { |r| render(r, &block) }
when Proc, Method
if renderable.arity == 0
yield_content_with_no_args(&renderable)
else
yield_content(&renderable)
end
when String
plain(renderable)
else
raise ArgumentError, "You can't render a #{renderable.inspect}."
end
nil
end
# Like {#capture} but the output is vanished into a BlackHole buffer.
# Because the BlackHole does nothing with the output, this should be faster.
# @return [nil]
# @api private
def __vanish__(*args)
return unless block_given?
@_context.capturing_into(BlackHole) { yield(*args) }
nil
end
# Determines if the component should render. By default, it returns `true`.
# @abstract Override to define your own predicate to prevent rendering.
# @return [Boolean]
def render?
true
end
# Format the object for output
# @abstract Override to define your own format handling for different object types. Please remember to call `super` in the case that the passed object doesn't match, so that object formatting can be added at different layers of the inheritance tree.
# @return [String]
def format_object(object)
case object
when Float, Integer
object.to_s
end
end
# @abstract Override this method to hook in around a template render. You can do things before and after calling `super` to render the template. You should always call `super` so that callbacks can be added at different layers of the inheritance tree.
# @return [nil]
def around_template
before_template
yield
after_template
nil
end
# @abstract Override this method to hook in right before a template is rendered. Please remember to call `super` so that callbacks can be added at different layers of the inheritance tree.
# @return [nil]
def before_template
nil
end
# @abstract Override this method to hook in right after a template is rendered. Please remember to call `super` so that callbacks can be added at different layers of the inheritance tree.
# @return [nil]
def after_template
nil
end
# Yields the block and checks if it buffered anything. If nothing was buffered, the return value is treated as text. The text is always HTML-escaped.
# @yieldparam component [self]
# @return [nil]
def yield_content
return unless block_given?
target = @_context.target
original_length = target.length
content = yield(self)
__text__(content) if original_length == target.length
nil
end
# Same as {#yield_content} but yields no arguments.
# @yield Yields the block with no arguments.
def yield_content_with_no_args
return unless block_given?
target = @_context.target
original_length = target.length
content = yield
__text__(content) if original_length == target.length
nil
end
# Same as {#yield_content} but accepts a splat of arguments to yield. This is slightly slower than {#yield_content}.
# @yield [*args] Yields the given arguments.
# @return [nil]
def yield_content_with_args(*args)
return unless block_given?
target = @_context.target
original_length = target.length
content = yield(*args)
__text__(content) if original_length == target.length
nil
end
# Performs the same task as the public method #plain, but does not raise an error if an unformattable object is passed
# @api private
def __text__(content)
case content
when String
@_context.target << ERB::Escape.html_escape(content)
when Symbol
@_context.target << ERB::Escape.html_escape(content.name)
when nil
nil
else
if (formatted_object = format_object(content))
@_context.target << ERB::Escape.html_escape(formatted_object)
else
return false
end
end
true
end
# @api private
def __attributes__(**attributes)
__final_attributes__(**attributes).tap do |buffer|
Phlex::ATTRIBUTE_CACHE[respond_to?(:process_attributes) ? (attributes.hash + self.class.hash) : attributes.hash] = buffer.freeze
end
end
# @api private
def __final_attributes__(**attributes)
if respond_to?(:process_attributes)
attributes = process_attributes(**attributes)
end
if attributes[:href]&.start_with?(/\s*javascript:/)
attributes.delete(:href)
end
if attributes["href"]&.start_with?(/\s*javascript:/)
attributes.delete("href")
end
buffer = +""
__build_attributes__(attributes, buffer: buffer)
buffer
end
# @api private
def __build_attributes__(attributes, buffer:)
attributes.each do |k, v|
next unless v
name = case k
when String then k
when Symbol then k.name.tr("_", "-")
else raise ArgumentError, "Attribute keys should be Strings or Symbols."
end
# Detect unsafe attribute names. Attribute names are considered unsafe if they match an event attribute or include unsafe characters.
if HTML::EVENT_ATTRIBUTES[name] || name.match?(/[<>&"']/)
raise ArgumentError, "Unsafe attribute name detected: #{k}."
end
case v
when true
buffer << " " << name
when String
buffer << " " << name << '="' << ERB::Escape.html_escape(v) << '"'
when Symbol
buffer << " " << name << '="' << ERB::Escape.html_escape(v.name) << '"'
when Integer, Float
buffer << " " << name << '="' << v.to_s << '"'
when Hash
__build_attributes__(
v.transform_keys { |subkey|
case subkey
when Symbol then"#{name}-#{subkey.name.tr('_', '-')}"
else "#{name}-#{subkey}"
end
}, buffer: buffer
)
when Array
buffer << " " << name << '="' << ERB::Escape.html_escape(v.compact.join(" ")) << '"'
when Set
buffer << " " << name << '="' << ERB::Escape.html_escape(v.to_a.compact.join(" ")) << '"'
else
buffer << " " << name << '="' << ERB::Escape.html_escape(v.to_str) << '"'
end
end
buffer
end
end
end