phlex 1.10.3 → 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,476 +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 the method to `view_template` instead."
75
- end
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.")
76
84
  end
85
+ end
77
86
 
78
- def view_template(&block)
79
- template(&block)
80
- end
87
+ def to_proc
88
+ proc { |c| c.render(self) }
89
+ end
81
90
 
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
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
94
97
 
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
98
+ raise Phlex::DoubleRenderError.new("You can't render a #{self.class.name} more than once.") if @_rendered
99
+ @_rendered = true
101
100
 
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 fragments
109
- warn "⚠️ [WARNING] Selective Rendering is experimental, incomplete, and may change in future versions."
110
- @_context.target_fragments(fragments)
111
- end
101
+ if fragments
102
+ @_context.target_fragments(fragments)
103
+ end
112
104
 
113
- block ||= @_content_block
105
+ block ||= @_content_block
114
106
 
115
- return "" unless render?
107
+ return "" unless render?
116
108
 
117
- if !parent && Phlex::SUPPORTS_FIBER_STORAGE
118
- original_fiber_storage = Fiber[:__phlex_component__]
119
- Fiber[:__phlex_component__] = self
120
- end
109
+ if !parent && Phlex::SUPPORTS_FIBER_STORAGE
110
+ original_fiber_storage = Fiber[:__phlex_component__]
111
+ Fiber[:__phlex_component__] = self
112
+ end
121
113
 
122
- @_context.around_render do
123
- around_template do
124
- if block
125
- if is_a?(DeferredRender)
126
- __vanish__(self, &block)
127
- view_template
128
- else
129
- view_template do |*args|
130
- if args.length > 0
131
- yield_content_with_args(*args, &block)
132
- else
133
- yield_content(&block)
134
- 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)
135
126
  end
136
127
  end
137
- else
138
- view_template
139
128
  end
129
+ else
130
+ view_template
140
131
  end
141
132
  end
133
+ end
142
134
 
143
- unless parent
144
- if Phlex::SUPPORTS_FIBER_STORAGE
145
- Fiber[:__phlex_component__] = original_fiber_storage
146
- end
147
- buffer << context.buffer
135
+ unless parent
136
+ if Phlex::SUPPORTS_FIBER_STORAGE
137
+ Fiber[:__phlex_component__] = original_fiber_storage
148
138
  end
139
+ buffer << context.buffer
149
140
  end
141
+ end
150
142
 
151
- # Access the current render context data
152
- # @return the supplied context object, by default a Hash
153
- def context
154
- @_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")
155
156
  end
156
157
 
157
- # Output text content. The text will be HTML-escaped.
158
- # @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`
159
- # @return [nil]
160
- # @see #format_object
161
- def plain(content)
162
- unless __text__(content)
163
- 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"
164
- end
158
+ nil
159
+ end
165
160
 
166
- nil
167
- 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
168
167
 
169
- # 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.
170
- # @return [nil]
171
- # @yield If a block is given, it yields the block with no arguments.
172
- def whitespace(&block)
173
- context = @_context
174
- return if context.fragments && !context.in_target_fragment
168
+ buffer = context.buffer
175
169
 
176
- buffer = context.buffer
170
+ buffer << " "
177
171
 
172
+ if block_given?
173
+ yield_content(&)
178
174
  buffer << " "
175
+ end
179
176
 
180
- if block_given?
181
- yield_content(&block)
182
- buffer << " "
183
- end
177
+ nil
178
+ end
184
179
 
185
- nil
186
- 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 << " -->"
191
+
192
+ nil
193
+ end
187
194
 
188
- # Output an HTML comment.
189
- # @return [nil]
190
- def comment(&block)
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
191
201
  context = @_context
192
202
  return if context.fragments && !context.in_target_fragment
193
203
 
194
- 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
195
209
 
196
- buffer << "<!-- "
197
- yield_content(&block)
198
- buffer << " -->"
210
+ nil
211
+ end
199
212
 
200
- nil
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
218
+
219
+ if args.length > 0
220
+ @_context.capturing_into(+"") { yield_content_with_args(*args, &block) }
221
+ else
222
+ @_context.capturing_into(+"") { yield_content(&block) }
201
223
  end
224
+ end
202
225
 
203
- # 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.
204
- # @param content [String|nil]
205
- # @return [nil]
206
- def unsafe_raw(content = nil)
207
- 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
208
232
 
209
- context = @_context
210
- 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
211
236
 
212
- context.buffer << content
213
- nil
237
+ if registered_elements[normalized_name]
238
+ public_send(normalized_name, ...)
239
+ else
240
+ raise Phlex::ArgumentError.new("Unknown tag: #{normalized_name}")
214
241
  end
242
+ end
215
243
 
216
- # Capture a block of output as a String.
217
- # @note This only works if the block's receiver is the current component or the block returns a String.
218
- # @return [String]
219
- def capture(*args, &block)
220
- return "" unless block
244
+ def safe(value)
245
+ Phlex::SGML::SafeValue.new(value)
246
+ end
221
247
 
222
- if args.length > 0
223
- @_context.capturing_into(+"") { yield_content_with_args(*args, &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
257
+
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)
224
271
  else
225
- @_context.capturing_into(+"") { yield_content(&block) }
272
+ yield_content(&renderable)
226
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}.")
227
280
  end
228
281
 
229
- private
282
+ nil
283
+ end
230
284
 
231
- # @api private
232
- def flush
233
- return if @_context.capturing
285
+ private
234
286
 
235
- buffer = @_context.buffer
236
- @_buffer << buffer.dup
237
- buffer.clear
238
- 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?
239
292
 
240
- # Render another component, block or enumerable
241
- # @return [nil]
242
- # @overload render(component, &block)
243
- # Renders the component.
244
- # @param component [Phlex::SGML]
245
- # @overload render(component_class, &block)
246
- # Renders a new instance of the component class. This is useful for component classes that take no arguments.
247
- # @param component_class [Class<Phlex::SGML>]
248
- # @overload render(proc)
249
- # Renders the proc with {#yield_content}.
250
- # @param proc [Proc]
251
- # @overload render(enumerable)
252
- # Renders each item of the enumerable.
253
- # @param enumerable [Enumerable]
254
- # @example
255
- # render @items
256
- def render(renderable, &block)
257
- case renderable
258
- when Phlex::SGML
259
- renderable.call(@_buffer, context: @_context, view_context: @_view_context, parent: self, &block)
260
- when Class
261
- if renderable < Phlex::SGML
262
- renderable.new.call(@_buffer, context: @_context, view_context: @_view_context, parent: self, &block)
263
- end
264
- when Enumerable
265
- renderable.each { |r| render(r, &block) }
266
- when Proc, Method
267
- if renderable.arity == 0
268
- yield_content_with_no_args(&renderable)
269
- else
270
- yield_content(&renderable)
271
- end
272
- when String
273
- plain(renderable)
274
- else
275
- raise ArgumentError, "You can't render a #{renderable.inspect}."
276
- end
293
+ @_context.capturing_into(Phlex::BlackHole) { yield(*args) }
277
294
 
278
- 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
279
312
  end
313
+ end
280
314
 
281
- # Like {#capture} but the output is vanished into a BlackHole buffer.
282
- # Because the BlackHole does nothing with the output, this should be faster.
283
- # @return [nil]
284
- # @api private
285
- def __vanish__(*args)
286
- 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
287
321
 
288
- @_context.capturing_into(BlackHole) { yield(*args) }
322
+ nil
323
+ end
289
324
 
290
- nil
291
- 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
292
330
 
293
- # Determines if the component should render. By default, it returns `true`.
294
- # @abstract Override to define your own predicate to prevent rendering.
295
- # @return [Boolean]
296
- def render?
297
- true
298
- 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
299
336
 
300
- # Format the object for output
301
- # @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.
302
- # @return [String]
303
- def format_object(object)
304
- case object
305
- when Float, Integer
306
- object.to_s
307
- end
308
- 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?
309
342
 
310
- # @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.
311
- # @return [nil]
312
- def around_template
313
- before_template
314
- yield
315
- after_template
343
+ buffer = @_context.buffer
316
344
 
317
- nil
318
- end
345
+ original_length = buffer.bytesize
346
+ content = yield(self)
347
+ __text__(content) if original_length == buffer.bytesize
319
348
 
320
- # @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.
321
- # @return [nil]
322
- def before_template
323
- nil
324
- end
349
+ nil
350
+ end
325
351
 
326
- # @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.
327
- # @return [nil]
328
- def after_template
329
- nil
330
- 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?
331
356
 
332
- # 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.
333
- # @yieldparam component [self]
334
- # @return [nil]
335
- def yield_content
336
- return unless block_given?
357
+ buffer = @_context.buffer
337
358
 
338
- buffer = @_context.buffer
359
+ original_length = buffer.bytesize
360
+ content = yield
361
+ __text__(content) if original_length == buffer.bytesize
339
362
 
340
- original_length = buffer.bytesize
341
- content = yield(self)
342
- __text__(content) if original_length == buffer.bytesize
363
+ nil
364
+ end
343
365
 
344
- nil
345
- 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?
346
371
 
347
- # Same as {#yield_content} but yields no arguments.
348
- # @yield Yields the block with no arguments.
349
- def yield_content_with_no_args
350
- return unless block_given?
372
+ buffer = @_context.buffer
351
373
 
352
- buffer = @_context.buffer
374
+ original_length = buffer.bytesize
375
+ content = yield(*)
376
+ __text__(content) if original_length == buffer.bytesize
353
377
 
354
- original_length = buffer.bytesize
355
- content = yield
356
- __text__(content) if original_length == buffer.bytesize
378
+ nil
379
+ end
357
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
358
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
359
400
  end
360
401
 
361
- # Same as {#yield_content} but accepts a splat of arguments to yield. This is slightly slower than {#yield_content}.
362
- # @yield [*args] Yields the given arguments.
363
- # @return [nil]
364
- def yield_content_with_args(*args)
365
- return unless block_given?
402
+ true
403
+ end
366
404
 
367
- buffer = @_context.buffer
405
+ # @api private
406
+ def __attributes__(attributes, buffer = +"")
407
+ attributes.each do |k, v|
408
+ next unless v
368
409
 
369
- original_length = buffer.bytesize
370
- content = yield(*args)
371
- __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
372
415
 
373
- nil
374
- end
416
+ lower_name = name.downcase
375
417
 
376
- # Performs the same task as the public method #plain, but does not raise an error if an unformattable object is passed
377
- # @api private
378
- def __text__(content)
379
- context = @_context
380
- 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
428
+
429
+ if name.match?(/[<>&"']/)
430
+ raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
431
+ end
381
432
 
382
- case content
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
383
440
  when String
384
- @_context.buffer << Phlex::Escape.html_escape(content)
441
+ buffer << " " << name << '="' << v.gsub('"', "&quot;") << '"'
385
442
  when Symbol
386
- @_context.buffer << Phlex::Escape.html_escape(content.name)
387
- when nil
388
- nil
389
- else
390
- if (formatted_object = format_object(content))
391
- @_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;") << '"'
392
452
  else
393
- 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)
394
463
  end
395
- end
396
464
 
397
- true
398
- 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
399
478
 
400
- # @api private
401
- def __attributes__(**attributes)
402
- __final_attributes__(**attributes).tap do |buffer|
403
- Phlex::ATTRIBUTE_CACHE[respond_to?(:process_attributes) ? (attributes.hash + self.class.hash) : attributes.hash] = buffer.freeze
479
+ buffer << " " << name << '="' << value.gsub('"', "&quot;") << '"'
404
480
  end
405
481
  end
406
482
 
407
- # @api private
408
- def __final_attributes__(**attributes)
409
- if respond_to?(:process_attributes)
410
- 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")
411
498
  end
412
499
 
413
- buffer = +""
414
- __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
415
525
 
416
526
  buffer
417
527
  end
528
+ end
418
529
 
419
- # @api private
420
- def __build_attributes__(attributes, buffer:)
421
- attributes.each do |k, v|
422
- next unless v
530
+ # @api private
531
+ def __nested_tokens__(tokens)
532
+ buffer = +""
423
533
 
424
- name = case k
425
- when String then k
426
- when Symbol then k.name.tr("_", "-")
427
- else raise ArgumentError, "Attribute keys should be Strings or Symbols."
428
- end
534
+ i, length = 0, tokens.length
429
535
 
430
- lower_name = name.downcase
431
- next if lower_name == "href" && v.to_s.downcase.tr("^a-z:", "").start_with?("javascript:")
536
+ while i < length
537
+ token = tokens[i]
432
538
 
433
- # Detect unsafe attribute names. Attribute names are considered unsafe if they match an event attribute or include unsafe characters.
434
- if HTML::EVENT_ATTRIBUTES.include?(lower_name.tr("^a-z-", "")) || name.match?(/[<>&"']/)
435
- raise ArgumentError, "Unsafe attribute name detected: #{k}."
539
+ case token
540
+ when String
541
+ if i > 0
542
+ buffer << " " << token
543
+ else
544
+ buffer << token
436
545
  end
437
-
438
- case v
439
- when true
440
- buffer << " " << name
441
- when String
442
- buffer << " " << name << '="' << Phlex::Escape.html_escape(v) << '"'
443
- when Symbol
444
- buffer << " " << name << '="' << Phlex::Escape.html_escape(v.name) << '"'
445
- when Integer, Float
446
- buffer << " " << name << '="' << v.to_s << '"'
447
- when Hash
448
- __build_attributes__(
449
- v.transform_keys { |subkey|
450
- case subkey
451
- when Symbol then"#{name}-#{subkey.name.tr('_', '-')}"
452
- else "#{name}-#{subkey}"
453
- end
454
- }, buffer: buffer
455
- )
456
- when Array
457
- buffer << " " << name << '="' << Phlex::Escape.html_escape(v.compact.join(" ")) << '"'
458
- when Set
459
- buffer << " " << name << '="' << Phlex::Escape.html_escape(v.to_a.compact.join(" ")) << '"'
546
+ when Symbol
547
+ if i > 0
548
+ buffer << " " << token.name.tr("_", "-")
460
549
  else
461
- value = if v.respond_to?(:to_phlex_attribute_value)
462
- v.to_phlex_attribute_value
463
- elsif v.respond_to?(:to_str)
464
- v.to_str
465
- else
466
- v.to_s
467
- end
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
559
+ end
560
+ end
561
+
562
+ i += 1
563
+ end
468
564
 
469
- buffer << " " << name << '="' << Phlex::Escape.html_escape(value) << '"'
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.")
470
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
471
596
  end
597
+ end
598
+ end
472
599
 
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.")
618
+ end
619
+
620
+ value = __styles__(v)
621
+
622
+ if value
623
+ buffer << prop << ":" << value
624
+ end
625
+ end
473
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
474
637
  end
638
+
639
+ style.end_with?(";") ? style : "#{style};"
475
640
  end
476
641
  end