phlex 1.11.1 → 2.0.0.beta1

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