phlex 1.11.0 → 2.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/phlex/sgml.rb CHANGED
@@ -1,485 +1,641 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Phlex
4
- # **Standard Generalized Markup Language** for behaviour common to {HTML} and {SVG}.
5
- class SGML
6
- include Helpers
7
-
8
- class << self
9
- # Render the view to a String. Arguments are delegated to {.new}.
10
- def call(...)
11
- new(...).call
12
- end
3
+ # **Standard Generalized Markup Language** for behaviour common to {HTML} and {SVG}.
4
+ class Phlex::SGML
5
+ autoload :SafeObject, "phlex/sgml/safe_object"
6
+ autoload :SafeValue, "phlex/sgml/safe_value"
13
7
 
14
- # Create a new instance of the component.
15
- # @note The block will not be delegated {#initialize}. Instead, it will be sent to {#template} when rendering.
16
- def new(*args, **kwargs, &block)
17
- if block
18
- object = super(*args, **kwargs, &nil)
19
- object.instance_variable_set(:@_content_block, block)
20
- object
21
- else
22
- super
23
- end
24
- end
8
+ include Phlex::Helpers
25
9
 
26
- # @api private
27
- def rendered_at_least_once!
28
- alias_method :__attributes__, :__final_attributes__
29
- alias_method :call, :__final_call__
30
- end
10
+ class << self
11
+ # Render the view to a String. Arguments are delegated to {.new}.
12
+ def call(...)
13
+ new(...).call
14
+ end
31
15
 
32
- # @api private
33
- def element_method?(method_name)
34
- return false unless instance_methods.include?(method_name)
16
+ # Create a new instance of the component.
17
+ # @note The block will not be delegated {#initialize}. Instead, it will be sent to {#template} when rendering.
18
+ def new(*, **, &block)
19
+ if block
20
+ object = super(*, **, &nil)
21
+ object.instance_exec { @_content_block = block }
22
+ object
23
+ else
24
+ super
25
+ end
26
+ end
35
27
 
28
+ # @api private
29
+ def __element_method__?(method_name)
30
+ if instance_methods.include?(method_name)
36
31
  owner = instance_method(method_name).owner
37
32
 
38
- return true if owner.is_a?(Phlex::Elements) && owner.registered_elements[method_name]
39
-
33
+ if Phlex::Elements === owner && owner.registered_elements[method_name]
34
+ true
35
+ else
36
+ false
37
+ end
38
+ else
40
39
  false
41
40
  end
42
41
  end
42
+ end
43
43
 
44
- # @!method initialize
45
- # @abstract Override to define an initializer for your component.
46
- # @note Your initializer will not receive a block passed to {.new}. Instead, this block will be sent to {#template} when rendering.
47
- # @example
48
- # def initialize(articles:)
49
- # @articles = articles
50
- # end
51
-
52
- # @abstract Override to define a template for your component.
53
- # @example
54
- # def view_template
55
- # h1 { "👋 Hello World!" }
56
- # end
57
- # @example Your template may yield a content block.
58
- # def view_template
59
- # main {
60
- # h1 { "Hello World" }
61
- # yield
62
- # }
63
- # end
64
- # @example Alternatively, you can delegate the content block to an element.
65
- # def view_template(&block)
66
- # article(class: "card", &block)
67
- # end
68
- def template
44
+ # @!method initialize
45
+ # @abstract Override to define an initializer for your component.
46
+ # @note Your initializer will not receive a block passed to {.new}. Instead, this block will be sent to {#template} when rendering.
47
+ # @example
48
+ # def initialize(articles:)
49
+ # @articles = articles
50
+ # end
51
+
52
+ # @abstract Override to define a template for your component.
53
+ # @example
54
+ # def view_template
55
+ # h1 { "👋 Hello World!" }
56
+ # end
57
+ # @example Your template may yield a content block.
58
+ # def view_template
59
+ # main {
60
+ # h1 { "Hello World" }
61
+ # yield
62
+ # }
63
+ # end
64
+ # @example Alternatively, you can delegate the content block to an element.
65
+ # def view_template(&block)
66
+ # article(class: "card", &block)
67
+ # end
68
+ def view_template
69
+ if block_given?
69
70
  yield
70
71
  end
72
+ end
71
73
 
72
- def self.method_added(method_name)
73
- if method_name == :template
74
- Kernel.warn "⚠️ [DEPRECATION] Defining the `template` method on a Phlex component will not be supported in Phlex 2.0. Please rename `#{name}#template` to `#{name}#view_template` instead."
75
- end
76
- end
77
-
78
- def view_template(&block)
79
- template(&block)
74
+ def await(task)
75
+ case task
76
+ when defined?(Concurrent::IVar) && Concurrent::IVar
77
+ flush if task.pending?
78
+ task.wait.value
79
+ when defined?(Async::Task) && Async::Task
80
+ flush if task.running?
81
+ task.wait
82
+ else
83
+ raise Phlex::ArgumentError.new("Expected an asynchronous task / promise.")
80
84
  end
85
+ end
81
86
 
82
- def await(task)
83
- case task
84
- when defined?(Concurrent::IVar) && Concurrent::IVar
85
- flush if task.pending?
86
- task.wait.value
87
- when defined?(Async::Task) && Async::Task
88
- flush if task.running?
89
- task.wait
90
- else
91
- raise ArgumentError, "Expected an asynchronous task / promise."
92
- end
93
- end
87
+ def to_proc
88
+ proc { |c| c.render(self) }
89
+ end
94
90
 
95
- # Renders the view and returns the buffer. The default buffer is a mutable String.
96
- def call(...)
97
- __final_call__(...).tap do
98
- self.class.rendered_at_least_once!
99
- end
100
- end
91
+ # Renders the view and returns the buffer. The default buffer is a mutable String.
92
+ def call(buffer = +"", context: Phlex::Context.new, view_context: nil, parent: nil, fragments: nil, &block)
93
+ @_buffer = buffer
94
+ @_context = context
95
+ @_view_context = view_context
96
+ @_parent = parent
101
97
 
102
- # @api private
103
- def __final_call__(buffer = +"", context: Phlex::Context.new, view_context: nil, parent: nil, fragments: nil, &block)
104
- @_buffer = buffer
105
- @_context = context
106
- @_view_context = view_context
107
- @_parent = parent
108
- if @_rendered
109
- warn "⚠️ [WARNING] You are rendering a component #{self.class.name} twice. This is not supported in Phlex 2.0."
110
- end
111
- @_rendered = true
98
+ raise Phlex::DoubleRenderError.new("You can't render a #{self.class.name} more than once.") if @_rendered
99
+ @_rendered = true
112
100
 
113
- if fragments
114
- warn "⚠️ [WARNING] Selective Rendering is experimental, incomplete, and may change in future versions."
115
- @_context.target_fragments(fragments)
116
- end
101
+ if fragments
102
+ @_context.target_fragments(fragments)
103
+ end
117
104
 
118
- block ||= @_content_block
105
+ block ||= @_content_block
119
106
 
120
- return "" unless render?
107
+ return "" unless render?
121
108
 
122
- if !parent && Phlex::SUPPORTS_FIBER_STORAGE
123
- original_fiber_storage = Fiber[:__phlex_component__]
124
- Fiber[:__phlex_component__] = self
125
- end
109
+ if !parent && Phlex::SUPPORTS_FIBER_STORAGE
110
+ original_fiber_storage = Fiber[:__phlex_component__]
111
+ Fiber[:__phlex_component__] = self
112
+ end
126
113
 
127
- @_context.around_render do
128
- around_template do
129
- if block
130
- if is_a?(DeferredRender)
131
- __vanish__(self, &block)
132
- view_template
133
- else
134
- view_template do |*args|
135
- if args.length > 0
136
- yield_content_with_args(*args, &block)
137
- else
138
- yield_content(&block)
139
- end
114
+ @_context.around_render do
115
+ around_template do
116
+ if block
117
+ if Phlex::DeferredRender === self
118
+ vanish(self, &block)
119
+ view_template
120
+ else
121
+ view_template do |*args|
122
+ if args.length > 0
123
+ yield_content_with_args(*args, &block)
124
+ else
125
+ yield_content(&block)
140
126
  end
141
127
  end
142
- else
143
- view_template
144
128
  end
129
+ else
130
+ view_template
145
131
  end
146
132
  end
133
+ end
147
134
 
148
- unless parent
149
- if Phlex::SUPPORTS_FIBER_STORAGE
150
- Fiber[:__phlex_component__] = original_fiber_storage
151
- end
152
- buffer << context.buffer
135
+ unless parent
136
+ if Phlex::SUPPORTS_FIBER_STORAGE
137
+ Fiber[:__phlex_component__] = original_fiber_storage
153
138
  end
139
+ buffer << context.buffer
154
140
  end
141
+ end
155
142
 
156
- # Access the current render context data
157
- # @return the supplied context object, by default a Hash
158
- def context
159
- @_context.user_context
143
+ # Access the current render context data
144
+ # @return the supplied context object, by default a Hash
145
+ def context
146
+ @_context.user_context
147
+ end
148
+
149
+ # Output text content. The text will be HTML-escaped.
150
+ # @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`
151
+ # @return [nil]
152
+ # @see #format_object
153
+ def plain(content)
154
+ unless __text__(content)
155
+ raise Phlex::ArgumentError.new("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")
160
156
  end
161
157
 
162
- # Output text content. The text will be HTML-escaped.
163
- # @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`
164
- # @return [nil]
165
- # @see #format_object
166
- def plain(content)
167
- unless __text__(content)
168
- 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"
169
- end
158
+ nil
159
+ end
170
160
 
171
- nil
172
- end
161
+ # 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.
162
+ # @return [nil]
163
+ # @yield If a block is given, it yields the block with no arguments.
164
+ def whitespace(&)
165
+ context = @_context
166
+ return if context.fragments && !context.in_target_fragment
173
167
 
174
- # 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.
175
- # @return [nil]
176
- # @yield If a block is given, it yields the block with no arguments.
177
- def whitespace(&block)
178
- context = @_context
179
- return if context.fragments && !context.in_target_fragment
168
+ buffer = context.buffer
180
169
 
181
- buffer = context.buffer
170
+ buffer << " "
182
171
 
172
+ if block_given?
173
+ yield_content(&)
183
174
  buffer << " "
175
+ end
184
176
 
185
- if block_given?
186
- yield_content(&block)
187
- buffer << " "
188
- end
177
+ nil
178
+ end
189
179
 
190
- nil
191
- end
180
+ # Output an HTML comment.
181
+ # @return [nil]
182
+ def comment(&)
183
+ context = @_context
184
+ return if context.fragments && !context.in_target_fragment
185
+
186
+ buffer = context.buffer
187
+
188
+ buffer << "<!-- "
189
+ yield_content(&)
190
+ buffer << " -->"
192
191
 
193
- # Output an HTML comment.
194
- # @return [nil]
195
- def comment(&block)
192
+ nil
193
+ end
194
+
195
+ # 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.
196
+ # @param content [String|nil]
197
+ # @return [nil]
198
+ def raw(content)
199
+ case content
200
+ when Phlex::SGML::SafeObject
196
201
  context = @_context
197
202
  return if context.fragments && !context.in_target_fragment
198
203
 
199
- buffer = context.buffer
204
+ context.buffer << content.to_s
205
+ when nil, "" # do nothing
206
+ else
207
+ raise Phlex::ArgumentError.new("You passed an unsafe object to `raw`.")
208
+ end
209
+
210
+ nil
211
+ end
200
212
 
201
- buffer << "<!-- "
202
- yield_content(&block)
203
- buffer << " -->"
213
+ # Capture a block of output as a String.
214
+ # @note This only works if the block's receiver is the current component or the block returns a String.
215
+ # @return [String]
216
+ def capture(*args, &block)
217
+ return "" unless block
204
218
 
205
- nil
219
+ if args.length > 0
220
+ @_context.capturing_into(+"") { yield_content_with_args(*args, &block) }
221
+ else
222
+ @_context.capturing_into(+"") { yield_content(&block) }
206
223
  end
224
+ end
207
225
 
208
- # 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.
209
- # @param content [String|nil]
210
- # @return [nil]
211
- def unsafe_raw(content = nil)
212
- return nil unless content
226
+ def tag(name, ...)
227
+ normalized_name = case name
228
+ when Symbol then name.name.downcase
229
+ when String then name.downcase
230
+ else raise Phlex::ArgumentError.new("Expected the tag name as a Symbol or String.")
231
+ end
213
232
 
214
- context = @_context
215
- return if context.fragments && !context.in_target_fragment
233
+ if normalized_name == "script"
234
+ raise Phlex::ArgumentError.new("You can’t use the `<script>` tag from the `tag` method. Use `unsafe_tag` instead, but be careful if using user input.")
235
+ end
216
236
 
217
- context.buffer << content
218
- nil
237
+ if registered_elements[normalized_name]
238
+ public_send(normalized_name, ...)
239
+ else
240
+ raise Phlex::ArgumentError.new("Unknown tag: #{normalized_name}")
219
241
  end
242
+ end
243
+
244
+ def safe(value)
245
+ Phlex::SGML::SafeValue.new(value)
246
+ end
220
247
 
221
- # Capture a block of output as a String.
222
- # @note This only works if the block's receiver is the current component or the block returns a String.
223
- # @return [String]
224
- def capture(*args, &block)
225
- return "" unless block
248
+ alias_method :🦺, :safe
249
+
250
+ def flush
251
+ return if @_context.capturing
252
+
253
+ buffer = @_context.buffer
254
+ @_buffer << buffer.dup
255
+ buffer.clear
256
+ end
226
257
 
227
- if args.length > 0
228
- @_context.capturing_into(+"") { yield_content_with_args(*args, &block) }
258
+ def render(renderable = nil, &)
259
+ case renderable
260
+ when Phlex::SGML
261
+ renderable.call(@_buffer, context: @_context, view_context: @_view_context, parent: self, &)
262
+ when Class
263
+ if renderable < Phlex::SGML
264
+ renderable.new.call(@_buffer, context: @_context, view_context: @_view_context, parent: self, &)
265
+ end
266
+ when Enumerable
267
+ renderable.each { |r| render(r, &) }
268
+ when Proc, Method
269
+ if renderable.arity == 0
270
+ yield_content_with_no_args(&renderable)
229
271
  else
230
- @_context.capturing_into(+"") { yield_content(&block) }
272
+ yield_content(&renderable)
231
273
  end
274
+ when String
275
+ plain(renderable)
276
+ when nil
277
+ yield_content(&) if block_given?
278
+ else
279
+ raise Phlex::ArgumentError.new("You can't render a #{renderable.inspect}.")
232
280
  end
233
281
 
234
- private
282
+ nil
283
+ end
235
284
 
236
- # @api private
237
- def flush
238
- return if @_context.capturing
285
+ private
239
286
 
240
- buffer = @_context.buffer
241
- @_buffer << buffer.dup
242
- buffer.clear
243
- end
287
+ # Like {#capture} but the output is vanished into a BlackHole buffer.
288
+ # Because the BlackHole does nothing with the output, this should be faster.
289
+ # @return [nil]
290
+ def vanish(*args)
291
+ return unless block_given?
244
292
 
245
- # Render another component, block or enumerable
246
- # @return [nil]
247
- # @overload render(component, &block)
248
- # Renders the component.
249
- # @param component [Phlex::SGML]
250
- # @overload render(component_class, &block)
251
- # Renders a new instance of the component class. This is useful for component classes that take no arguments.
252
- # @param component_class [Class<Phlex::SGML>]
253
- # @overload render(proc)
254
- # Renders the proc with {#yield_content}.
255
- # @param proc [Proc]
256
- # @overload render(enumerable)
257
- # Renders each item of the enumerable.
258
- # @param enumerable [Enumerable]
259
- # @example
260
- # render @items
261
- def render(renderable, &block)
262
- case renderable
263
- when Phlex::SGML
264
- renderable.call(@_buffer, context: @_context, view_context: @_view_context, parent: self, &block)
265
- when Class
266
- if renderable < Phlex::SGML
267
- renderable.new.call(@_buffer, context: @_context, view_context: @_view_context, parent: self, &block)
268
- end
269
- when Enumerable
270
- renderable.each { |r| render(r, &block) }
271
- when Proc, Method
272
- if renderable.arity == 0
273
- yield_content_with_no_args(&renderable)
274
- else
275
- yield_content(&renderable)
276
- end
277
- when String
278
- plain(renderable)
279
- else
280
- raise ArgumentError, "You can't render a #{renderable.inspect}."
281
- end
293
+ @_context.capturing_into(Phlex::BlackHole) { yield(*args) }
282
294
 
283
- nil
295
+ nil
296
+ end
297
+
298
+ # Determines if the component should render. By default, it returns `true`.
299
+ # @abstract Override to define your own predicate to prevent rendering.
300
+ # @return [Boolean]
301
+ def render?
302
+ true
303
+ end
304
+
305
+ # Format the object for output
306
+ # @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.
307
+ # @return [String]
308
+ def format_object(object)
309
+ case object
310
+ when Float, Integer
311
+ object.to_s
284
312
  end
313
+ end
285
314
 
286
- # Like {#capture} but the output is vanished into a BlackHole buffer.
287
- # Because the BlackHole does nothing with the output, this should be faster.
288
- # @return [nil]
289
- # @api private
290
- def __vanish__(*args)
291
- return unless block_given?
315
+ # @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.
316
+ # @return [nil]
317
+ def around_template
318
+ before_template
319
+ yield
320
+ after_template
292
321
 
293
- @_context.capturing_into(BlackHole) { yield(*args) }
322
+ nil
323
+ end
294
324
 
295
- nil
296
- end
325
+ # @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.
326
+ # @return [nil]
327
+ def before_template
328
+ nil
329
+ end
297
330
 
298
- # Determines if the component should render. By default, it returns `true`.
299
- # @abstract Override to define your own predicate to prevent rendering.
300
- # @return [Boolean]
301
- def render?
302
- true
303
- end
331
+ # @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.
332
+ # @return [nil]
333
+ def after_template
334
+ nil
335
+ end
304
336
 
305
- # Format the object for output
306
- # @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.
307
- # @return [String]
308
- def format_object(object)
309
- case object
310
- when Float, Integer
311
- object.to_s
312
- end
313
- end
337
+ # 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.
338
+ # @yieldparam component [self]
339
+ # @return [nil]
340
+ def yield_content
341
+ return unless block_given?
314
342
 
315
- # @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.
316
- # @return [nil]
317
- def around_template
318
- before_template
319
- yield
320
- after_template
343
+ buffer = @_context.buffer
321
344
 
322
- nil
323
- end
345
+ original_length = buffer.bytesize
346
+ content = yield(self)
347
+ __text__(content) if original_length == buffer.bytesize
324
348
 
325
- # @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.
326
- # @return [nil]
327
- def before_template
328
- nil
329
- end
349
+ nil
350
+ end
330
351
 
331
- # @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.
332
- # @return [nil]
333
- def after_template
334
- nil
335
- end
352
+ # Same as {#yield_content} but yields no arguments.
353
+ # @yield Yields the block with no arguments.
354
+ def yield_content_with_no_args
355
+ return unless block_given?
336
356
 
337
- # 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.
338
- # @yieldparam component [self]
339
- # @return [nil]
340
- def yield_content
341
- return unless block_given?
357
+ buffer = @_context.buffer
342
358
 
343
- buffer = @_context.buffer
359
+ original_length = buffer.bytesize
360
+ content = yield
361
+ __text__(content) if original_length == buffer.bytesize
344
362
 
345
- original_length = buffer.bytesize
346
- content = yield(self)
347
- __text__(content) if original_length == buffer.bytesize
363
+ nil
364
+ end
348
365
 
349
- nil
350
- end
366
+ # Same as {#yield_content} but accepts a splat of arguments to yield. This is slightly slower than {#yield_content}.
367
+ # @yield [*args] Yields the given arguments.
368
+ # @return [nil]
369
+ def yield_content_with_args(*)
370
+ return unless block_given?
351
371
 
352
- # Same as {#yield_content} but yields no arguments.
353
- # @yield Yields the block with no arguments.
354
- def yield_content_with_no_args
355
- return unless block_given?
372
+ buffer = @_context.buffer
356
373
 
357
- buffer = @_context.buffer
374
+ original_length = buffer.bytesize
375
+ content = yield(*)
376
+ __text__(content) if original_length == buffer.bytesize
358
377
 
359
- original_length = buffer.bytesize
360
- content = yield
361
- __text__(content) if original_length == buffer.bytesize
378
+ nil
379
+ end
362
380
 
381
+ # Performs the same task as the public method #plain, but does not raise an error if an unformattable object is passed
382
+ # @api private
383
+ def __text__(content)
384
+ context = @_context
385
+ return true if context.fragments && !context.in_target_fragment
386
+
387
+ case content
388
+ when String
389
+ context.buffer << Phlex::Escape.html_escape(content)
390
+ when Symbol
391
+ context.buffer << Phlex::Escape.html_escape(content.name)
392
+ when nil
363
393
  nil
394
+ else
395
+ if (formatted_object = format_object(content))
396
+ context.buffer << Phlex::Escape.html_escape(formatted_object)
397
+ else
398
+ return false
399
+ end
364
400
  end
365
401
 
366
- # Same as {#yield_content} but accepts a splat of arguments to yield. This is slightly slower than {#yield_content}.
367
- # @yield [*args] Yields the given arguments.
368
- # @return [nil]
369
- def yield_content_with_args(*args)
370
- return unless block_given?
402
+ true
403
+ end
371
404
 
372
- buffer = @_context.buffer
405
+ # @api private
406
+ def __attributes__(attributes, buffer = +"")
407
+ attributes.each do |k, v|
408
+ next unless v
373
409
 
374
- original_length = buffer.bytesize
375
- content = yield(*args)
376
- __text__(content) if original_length == buffer.bytesize
410
+ name = case k
411
+ when String then k
412
+ when Symbol then k.name.tr("_", "-")
413
+ else raise Phlex::ArgumentError.new("Attribute keys should be Strings or Symbols.")
414
+ end
377
415
 
378
- nil
379
- end
416
+ lower_name = name.downcase
380
417
 
381
- # Performs the same task as the public method #plain, but does not raise an error if an unformattable object is passed
382
- # @api private
383
- def __text__(content)
384
- context = @_context
385
- return true if context.fragments && !context.in_target_fragment
418
+ unless Phlex::SGML::SafeObject === v
419
+ if lower_name == "href" && v.to_s.downcase.delete("^a-z:").start_with?("javascript:")
420
+ next
421
+ end
422
+
423
+ # Detect unsafe attribute names. Attribute names are considered unsafe if they match an event attribute or include unsafe characters.
424
+ if Phlex::HTML::UNSAFE_ATTRIBUTES.include?(lower_name.delete("^a-z-"))
425
+ raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
426
+ end
427
+ end
386
428
 
387
- case content
429
+ if name.match?(/[<>&"']/)
430
+ raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
431
+ end
432
+
433
+ if lower_name.to_sym == :id && k != :id
434
+ raise Phlex::ArgumentError.new(":id attribute should only be passed as a lowercase symbol.")
435
+ end
436
+
437
+ case v
438
+ when true
439
+ buffer << " " << name
388
440
  when String
389
- @_context.buffer << Phlex::Escape.html_escape(content)
441
+ buffer << " " << name << '="' << v.gsub('"', "&quot;") << '"'
390
442
  when Symbol
391
- @_context.buffer << Phlex::Escape.html_escape(content.name)
392
- when nil
393
- nil
394
- else
395
- if (formatted_object = format_object(content))
396
- @_context.buffer << Phlex::Escape.html_escape(formatted_object)
443
+ buffer << " " << name << '="' << v.name.tr("_", "-").gsub('"', "&quot;") << '"'
444
+ when Integer, Float
445
+ buffer << " " << name << '="' << v.to_s << '"'
446
+ when Hash
447
+ case k
448
+ when :class
449
+ buffer << " " << name << '="' << __classes__(v).gsub('"', "&quot;") << '"'
450
+ when :style
451
+ buffer << " " << name << '="' << __styles__(v).gsub('"', "&quot;") << '"'
397
452
  else
398
- return false
453
+ __nested_attributes__(v, "#{name}-", buffer)
454
+ end
455
+ when Array
456
+ value = case k
457
+ when :class
458
+ __classes__(v)
459
+ when :style
460
+ __styles__(v)
461
+ else
462
+ __nested_tokens__(v)
399
463
  end
400
- end
401
464
 
402
- true
403
- end
465
+ buffer << " " << name << '="' << value.gsub('"', "&quot;") << '"'
466
+ when Set
467
+ buffer << " " << name << '="' << __nested_tokens__(v.to_a) << '"'
468
+ when Phlex::SGML::SafeObject
469
+ buffer << " " << name << '="' << v.to_s.gsub('"', "&quot;") << '"'
470
+ else
471
+ value = if v.respond_to?(:to_phlex_attribute_value)
472
+ v.to_phlex_attribute_value
473
+ elsif v.respond_to?(:to_str)
474
+ v.to_str
475
+ else
476
+ v.to_s
477
+ end
404
478
 
405
- # @api private
406
- def __attributes__(**attributes)
407
- __final_attributes__(**attributes).tap do |buffer|
408
- Phlex::ATTRIBUTE_CACHE[respond_to?(:process_attributes) ? (attributes.hash + self.class.hash) : attributes.hash] = buffer.freeze
479
+ buffer << " " << name << '="' << value.gsub('"', "&quot;") << '"'
409
480
  end
410
481
  end
411
482
 
412
- # @api private
413
- def __final_attributes__(**attributes)
414
- if respond_to?(:process_attributes)
415
- attributes = process_attributes(**attributes)
483
+ buffer
484
+ end
485
+
486
+ # @api private
487
+ #
488
+ # Provides the nested-attributes case for serializing out attributes.
489
+ # This allows us to skip many of the checks the `__attributes__` method must perform.
490
+ def __nested_attributes__(attributes, base_name, buffer = +"")
491
+ attributes.each do |k, v|
492
+ next unless v
493
+
494
+ name = case k
495
+ when String then k
496
+ when Symbol then k.name.tr("_", "-")
497
+ else raise Phlex::ArgumentError.new("Attribute keys should be Strings or Symbols")
416
498
  end
417
499
 
418
- buffer = +""
419
- __build_attributes__(attributes, buffer: buffer)
500
+ case v
501
+ when true
502
+ buffer << " " << base_name << name
503
+ when String
504
+ buffer << " " << base_name << name << '="' << v.gsub('"', "&quot;") << '"'
505
+ when Symbol
506
+ buffer << " " << base_name << name << '="' << v.name.tr("_", "-").gsub('"', "&quot;") << '"'
507
+ when Integer, Float
508
+ buffer << " " << base_name << name << '="' << v.to_s << '"'
509
+ when Hash
510
+ __nested_attributes__(v, "#{base_name}#{name}-", buffer)
511
+ when Array
512
+ buffer << " " << base_name << name << '="' << __nested_tokens__(v) << '"'
513
+ when Set
514
+ buffer << " " << base_name << name << '="' << __nested_tokens__(v.to_a) << '"'
515
+ else
516
+ value = if v.respond_to?(:to_phlex_attribute_value)
517
+ v.to_phlex_attribute_value
518
+ elsif v.respond_to?(:to_str)
519
+ v.to_str
520
+ else
521
+ v.to_s
522
+ end
523
+ buffer << " " << base_name << name << '="' << value.gsub('"', "&quot;") << '"'
524
+ end
420
525
 
421
526
  buffer
422
527
  end
528
+ end
423
529
 
424
- # @api private
425
- def __build_attributes__(attributes, buffer:)
426
- attributes.each do |k, v|
427
- next unless v
530
+ # @api private
531
+ def __nested_tokens__(tokens)
532
+ buffer = +""
428
533
 
429
- name = case k
430
- when String then k
431
- when Symbol then k.name.tr("_", "-")
432
- else raise ArgumentError, "Attribute keys should be Strings or Symbols."
534
+ i, length = 0, tokens.length
535
+
536
+ while i < length
537
+ token = tokens[i]
538
+
539
+ case token
540
+ when String
541
+ if i > 0
542
+ buffer << " " << token
543
+ else
544
+ buffer << token
545
+ end
546
+ when Symbol
547
+ if i > 0
548
+ buffer << " " << token.name.tr("_", "-")
549
+ else
550
+ buffer << token.name.tr("_", "-")
551
+ end
552
+ when nil
553
+ # Do nothing
554
+ else
555
+ if i > 0
556
+ buffer << " " << token.to_s
557
+ else
558
+ buffer << token.to_s
433
559
  end
560
+ end
434
561
 
435
- lower_name = name.downcase
436
- next if lower_name == "href" && v.to_s.downcase.tr("^a-z:", "").start_with?("javascript:")
562
+ i += 1
563
+ end
437
564
 
438
- # Detect unsafe attribute names. Attribute names are considered unsafe if they match an event attribute or include unsafe characters.
439
- if HTML::EVENT_ATTRIBUTES.include?(lower_name.tr("^a-z-", "")) || name.match?(/[<>&"']/)
440
- raise ArgumentError, "Unsafe attribute name detected: #{k}."
565
+ buffer.gsub!('"', "&quot;")
566
+ buffer
567
+ end
568
+
569
+ # @api private
570
+ def __classes__(c)
571
+ case c
572
+ when String
573
+ c
574
+ when Symbol
575
+ c.name.tr("_", "-")
576
+ when Array, Set
577
+ c.filter_map { |c| __classes__(c) }.join(" ")
578
+ when Hash
579
+ c.filter_map { |c, add|
580
+ next unless add
581
+ case c
582
+ when String then c
583
+ when Symbol then c.name.tr("_", "-").delete_suffix("?")
584
+ else raise Phlex::ArgumentError.new("Class keys should be Strings or Symbols.")
441
585
  end
586
+ }.join(" ")
587
+ when nil, false
588
+ nil
589
+ else
590
+ if c.respond_to?(:to_phlex_attribute_value)
591
+ c.to_phlex_attribute_value
592
+ elsif c.respond_to?(:to_str)
593
+ c.to_str
594
+ else
595
+ c.to_s
596
+ end
597
+ end
598
+ end
442
599
 
443
- if lower_name.to_sym == :id && k != :id
444
- warn "⚠️ [WARNING] Starting 2.0 Phlex will raise on non lowercase or string :id attribute."
600
+ # @api private
601
+ def __styles__(s)
602
+ style = case s
603
+ when String
604
+ s
605
+ when Symbol
606
+ s.name.tr("_", "-")
607
+ when Integer, Float
608
+ s.to_s
609
+ when Array, Set
610
+ s.filter_map { |s| __styles__(s) }.join
611
+ when Hash
612
+ buffer = +""
613
+ s.each do |k, v|
614
+ prop = case k
615
+ when String then k
616
+ when Symbol then k.name.tr("_", "-")
617
+ else raise Phlex::ArgumentError.new("Style keys should be Strings or Symbols.")
445
618
  end
446
619
 
447
- case v
448
- when true
449
- buffer << " " << name
450
- when String
451
- buffer << " " << name << '="' << Phlex::Escape.html_escape(v) << '"'
452
- when Symbol
453
- buffer << " " << name << '="' << Phlex::Escape.html_escape(v.name) << '"'
454
- when Integer, Float
455
- buffer << " " << name << '="' << v.to_s << '"'
456
- when Hash
457
- __build_attributes__(
458
- v.transform_keys { |subkey|
459
- case subkey
460
- when Symbol then"#{name}-#{subkey.name.tr('_', '-')}"
461
- else "#{name}-#{subkey}"
462
- end
463
- }, buffer: buffer
464
- )
465
- when Array
466
- buffer << " " << name << '="' << Phlex::Escape.html_escape(v.compact.join(" ")) << '"'
467
- when Set
468
- buffer << " " << name << '="' << Phlex::Escape.html_escape(v.to_a.compact.join(" ")) << '"'
469
- else
470
- value = if v.respond_to?(:to_phlex_attribute_value)
471
- v.to_phlex_attribute_value
472
- elsif v.respond_to?(:to_str)
473
- v.to_str
474
- else
475
- v.to_s
476
- end
620
+ value = __styles__(v)
477
621
 
478
- buffer << " " << name << '="' << Phlex::Escape.html_escape(value) << '"'
622
+ if value
623
+ buffer << prop << ":" << value
479
624
  end
480
625
  end
481
-
482
626
  buffer
627
+ when nil, false
628
+ return nil
629
+ else
630
+ if s.respond_to?(:to_phlex_attribute_value)
631
+ s.to_phlex_attribute_value
632
+ elsif s.respond_to?(:to_str)
633
+ s.to_str
634
+ else
635
+ s.to_s
636
+ end
483
637
  end
638
+
639
+ style.end_with?(";") ? style : "#{style};"
484
640
  end
485
641
  end