phlex 2.0.0.beta2 → 2.0.0.rc2

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
@@ -2,8 +2,13 @@
2
2
 
3
3
  # **Standard Generalized Markup Language** for behaviour common to {HTML} and {SVG}.
4
4
  class Phlex::SGML
5
+ UNSAFE_ATTRIBUTES = Set.new(%w[srcdoc sandbox http-equiv]).freeze
6
+ REF_ATTRIBUTES = Set.new(%w[href src action formaction lowsrc dynsrc background ping]).freeze
7
+
8
+ autoload :Elements, "phlex/sgml/elements"
5
9
  autoload :SafeObject, "phlex/sgml/safe_object"
6
10
  autoload :SafeValue, "phlex/sgml/safe_value"
11
+ autoload :State, "phlex/sgml/state"
7
12
 
8
13
  include Phlex::Helpers
9
14
 
@@ -15,72 +20,22 @@ class Phlex::SGML
15
20
 
16
21
  # Create a new instance of the component.
17
22
  # @note The block will not be delegated {#initialize}. Instead, it will be sent to {#template} when rendering.
18
- def new(*, **, &block)
23
+ def new(*a, **k, &block)
19
24
  if block
20
- object = super(*, **, &nil)
25
+ object = super(*a, **k, &nil)
21
26
  object.instance_exec { @_content_block = block }
22
27
  object
23
28
  else
24
29
  super
25
30
  end
26
31
  end
27
-
28
- # @api private
29
- def __element_method__?(method_name)
30
- if instance_methods.include?(method_name)
31
- owner = instance_method(method_name).owner
32
-
33
- if Phlex::Elements === owner && owner.registered_elements[method_name]
34
- true
35
- else
36
- false
37
- end
38
- else
39
- false
40
- end
41
- end
42
32
  end
43
33
 
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
34
  def view_template
69
35
  if block_given?
70
36
  yield
71
- end
72
- end
73
-
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
37
  else
83
- raise Phlex::ArgumentError.new("Expected an asynchronous task / promise.")
38
+ plain "Phlex Warning: Your `#{self.class.name}` class doesn't define a `view_template` method. If you are upgrading to Phlex 2.x make sure to rename your `template` method to `view_template`. See: https://beta.phlex.fun/guides/v2-upgrade.html"
84
39
  end
85
40
  end
86
41
 
@@ -88,68 +43,62 @@ class Phlex::SGML
88
43
  proc { |c| c.render(self) }
89
44
  end
90
45
 
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
46
+ def call(buffer = +"", context: {}, view_context: nil, fragments: nil, &)
47
+ state = Phlex::SGML::State.new(
48
+ user_context: context,
49
+ view_context:,
50
+ output_buffer: buffer,
51
+ fragments: fragments&.to_set,
52
+ )
97
53
 
98
- raise Phlex::DoubleRenderError.new("You can't render a #{self.class.name} more than once.") if @_rendered
99
- @_rendered = true
54
+ internal_call(parent: nil, state:, &)
100
55
 
101
- if fragments
102
- @_context.target_fragments(fragments)
56
+ state.output_buffer << state.buffer
57
+ end
58
+
59
+ def internal_call(parent: nil, state: nil, &block)
60
+ return "" unless render?
61
+
62
+ if @_state
63
+ raise Phlex::DoubleRenderError.new(
64
+ "You can't render a #{self.class.name} more than once."
65
+ )
103
66
  end
104
67
 
68
+ @_state = state
69
+
105
70
  block ||= @_content_block
106
71
 
107
- return "" unless render?
72
+ Thread.current[:__phlex_component__] = [self, Fiber.current.object_id].freeze
108
73
 
109
- if !parent && Phlex::SUPPORTS_FIBER_STORAGE
110
- original_fiber_storage = Fiber[:__phlex_component__]
111
- Fiber[:__phlex_component__] = self
112
- end
74
+ state.around_render(self) do
75
+ before_template(&block)
113
76
 
114
- @_context.around_render do
115
77
  around_template do
116
78
  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)
126
- end
79
+ view_template do |*args|
80
+ if args.length > 0
81
+ __yield_content_with_args__(*args, &block)
82
+ else
83
+ __yield_content__(&block)
127
84
  end
128
85
  end
129
86
  else
130
87
  view_template
131
88
  end
132
89
  end
133
- end
134
90
 
135
- unless parent
136
- if Phlex::SUPPORTS_FIBER_STORAGE
137
- Fiber[:__phlex_component__] = original_fiber_storage
138
- end
139
- buffer << context.buffer
91
+ after_template(&block)
140
92
  end
93
+ ensure
94
+ Thread.current[:__phlex_component__] = [parent, Fiber.current.object_id].freeze
141
95
  end
142
96
 
143
- # Access the current render context data
144
- # @return the supplied context object, by default a Hash
145
97
  def context
146
- @_context.user_context
98
+ @_state.user_context
147
99
  end
148
100
 
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
101
+ # Output plain text.
153
102
  def plain(content)
154
103
  unless __text__(content)
155
104
  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")
@@ -158,50 +107,47 @@ class Phlex::SGML
158
107
  nil
159
108
  end
160
109
 
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.
110
+ # Output a single space character. If a block is given, a space will be output before and after the block.
164
111
  def whitespace(&)
165
- context = @_context
166
- return if context.fragments && !context.in_target_fragment
112
+ state = @_state
113
+ return unless state.should_render?
167
114
 
168
- buffer = context.buffer
115
+ buffer = state.buffer
169
116
 
170
117
  buffer << " "
171
118
 
172
119
  if block_given?
173
- yield_content(&)
120
+ __yield_content__(&)
174
121
  buffer << " "
175
122
  end
176
123
 
177
124
  nil
178
125
  end
179
126
 
180
- # Output an HTML comment.
181
- # @return [nil]
127
+ # Wrap the output in an HTML comment.
128
+ #
129
+ # [MDN Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Comments)
182
130
  def comment(&)
183
- context = @_context
184
- return if context.fragments && !context.in_target_fragment
131
+ state = @_state
132
+ return unless state.should_render?
185
133
 
186
- buffer = context.buffer
134
+ buffer = state.buffer
187
135
 
188
136
  buffer << "<!-- "
189
- yield_content(&)
137
+ __yield_content__(&)
190
138
  buffer << " -->"
191
139
 
192
140
  nil
193
141
  end
194
142
 
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]
143
+ # Output the given safe object as-is. You may need to use `safe` to mark a string as a safe object.
198
144
  def raw(content)
199
145
  case content
200
146
  when Phlex::SGML::SafeObject
201
- context = @_context
202
- return if context.fragments && !context.in_target_fragment
147
+ state = @_state
148
+ return unless state.should_render?
203
149
 
204
- context.buffer << content.to_s
150
+ state.buffer << content.to_s
205
151
  when nil, "" # do nothing
206
152
  else
207
153
  raise Phlex::ArgumentError.new("You passed an unsafe object to `raw`.")
@@ -210,71 +156,64 @@ class Phlex::SGML
210
156
  nil
211
157
  end
212
158
 
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]
159
+ # Capture the output of the block and returns it as a string.
216
160
  def capture(*args, &block)
217
161
  return "" unless block
218
162
 
219
163
  if args.length > 0
220
- @_context.capturing_into(+"") { yield_content_with_args(*args, &block) }
164
+ @_state.capturing_into(+"") { __yield_content_with_args__(*args, &block) }
221
165
  else
222
- @_context.capturing_into(+"") { yield_content(&block) }
166
+ @_state.capturing_into(+"") { __yield_content__(&block) }
223
167
  end
224
168
  end
225
169
 
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
232
-
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
236
-
237
- if registered_elements[normalized_name]
238
- public_send(normalized_name, ...)
239
- else
240
- raise Phlex::ArgumentError.new("Unknown tag: #{normalized_name}")
241
- end
170
+ # Define a named fragment that can be selectively rendered.
171
+ def fragment(name)
172
+ state = @_state
173
+ state.begin_fragment(name)
174
+ yield
175
+ state.end_fragment(name)
176
+ nil
242
177
  end
243
178
 
179
+ # Mark the given string as safe for HTML output.
244
180
  def safe(value)
245
- Phlex::SGML::SafeValue.new(value)
181
+ case value
182
+ when String
183
+ Phlex::SGML::SafeValue.new(value)
184
+ else
185
+ raise Phlex::ArgumentError.new("Expected a String.")
186
+ end
246
187
  end
247
188
 
248
189
  alias_method :🦺, :safe
249
190
 
250
191
  def flush
251
- return if @_context.capturing
252
-
253
- buffer = @_context.buffer
254
- @_buffer << buffer.dup
255
- buffer.clear
192
+ @_state.flush
256
193
  end
257
194
 
258
195
  def render(renderable = nil, &)
259
196
  case renderable
260
197
  when Phlex::SGML
261
- renderable.call(@_buffer, context: @_context, view_context: @_view_context, parent: self, &)
198
+ Thread.current[:__phlex_component__] = [renderable, Fiber.current.object_id].freeze
199
+ renderable.internal_call(state: @_state, parent: self, &)
200
+ Thread.current[:__phlex_component__] = [self, Fiber.current.object_id].freeze
262
201
  when Class
263
202
  if renderable < Phlex::SGML
264
- renderable.new.call(@_buffer, context: @_context, view_context: @_view_context, parent: self, &)
203
+ render(renderable.new, &)
265
204
  end
266
205
  when Enumerable
267
206
  renderable.each { |r| render(r, &) }
268
207
  when Proc, Method
269
208
  if renderable.arity == 0
270
- yield_content_with_no_args(&renderable)
209
+ __yield_content_with_no_args__(&renderable)
271
210
  else
272
- yield_content(&renderable)
211
+ __yield_content__(&renderable)
273
212
  end
274
213
  when String
275
214
  plain(renderable)
276
215
  when nil
277
- yield_content(&) if block_given?
216
+ __yield_content__(&) if block_given?
278
217
  else
279
218
  raise Phlex::ArgumentError.new("You can't render a #{renderable.inspect}.")
280
219
  end
@@ -282,29 +221,87 @@ class Phlex::SGML
282
221
  nil
283
222
  end
284
223
 
224
+ # Cache a block of content.
225
+ #
226
+ # ```ruby
227
+ # @products.each do |product|
228
+ # cache product do
229
+ # h1 { product.name }
230
+ # end
231
+ # end
232
+ # ```
233
+ def cache(*cache_key, **, &content)
234
+ location = caller_locations(1, 1)[0]
235
+
236
+ full_key = [
237
+ Phlex::DEPLOY_KEY, # invalidates the key when deploying new code in case of changes
238
+ self.class.name, # prevents collisions between classes
239
+ location.base_label, # prevents collisions between different methods
240
+ location.lineno, # prevents collisions between different lines
241
+ cache_key, # allows for custom cache keys
242
+ ].freeze
243
+
244
+ low_level_cache(full_key, **, &content)
245
+ end
246
+
247
+ # Cache a block of content where you control the entire cache key.
248
+ # If you really know what you’re doing and want to take full control
249
+ # and responsibility for the cache key, use this method.
250
+ #
251
+ # ```ruby
252
+ # low_level_cache([Commonmarker::VERSION, Digest::MD5.hexdigest(@content)]) do
253
+ # markdown(@content)
254
+ # end
255
+ # ```
256
+ #
257
+ # Note: To allow you more control, this method does not take a splat of cache keys.
258
+ # If you need to pass multiple cache keys, you should pass an array.
259
+ def low_level_cache(cache_key, **options, &content)
260
+ state = @_state
261
+
262
+ cached_buffer, fragment_map = cache_store.fetch(cache_key, **options) { state.caching(&content) }
263
+
264
+ if state.should_render?
265
+ fragment_map.each do |fragment_name, (offset, length, nested_fragments)|
266
+ state.record_fragment(fragment_name, offset, length, nested_fragments)
267
+ end
268
+ state.buffer << cached_buffer
269
+ else
270
+ fragment_map.each do |fragment_name, (offset, length, nested_fragments)|
271
+ if state.fragments.include?(fragment_name)
272
+ state.fragments.delete(fragment_name)
273
+ state.fragments.subtract(nested_fragments)
274
+ state.buffer << cached_buffer.byteslice(offset, length)
275
+ end
276
+ end
277
+ end
278
+ end
279
+
280
+ # Points to the cache store used by this component.
281
+ # By default, it points to `Phlex::NullCacheStore`, which does no caching.
282
+ # Override this method to use a different cache store.
283
+ def cache_store
284
+ Phlex::NullCacheStore
285
+ end
286
+
285
287
  private
286
288
 
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
289
  def vanish(*args)
291
290
  return unless block_given?
292
291
 
293
- @_context.capturing_into(Phlex::BlackHole) { yield(*args) }
292
+ if args.length > 0
293
+ @_state.capturing_into(Phlex::Vanish) { yield(*args) }
294
+ else
295
+ @_state.capturing_into(Phlex::Vanish) { yield(self) }
296
+ end
294
297
 
295
298
  nil
296
299
  end
297
300
 
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
301
  def render?
302
302
  true
303
303
  end
304
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
305
  def format_object(object)
309
306
  case object
310
307
  when Float, Integer
@@ -312,88 +309,94 @@ class Phlex::SGML
312
309
  end
313
310
  end
314
311
 
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
312
  def around_template
318
- before_template
319
313
  yield
320
- after_template
321
-
322
314
  nil
323
315
  end
324
316
 
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
317
  def before_template
328
318
  nil
329
319
  end
330
320
 
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
321
  def after_template
334
322
  nil
335
323
  end
336
324
 
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
325
+ def __yield_content__
341
326
  return unless block_given?
342
327
 
343
- buffer = @_context.buffer
328
+ buffer = @_state.buffer
344
329
 
345
330
  original_length = buffer.bytesize
346
331
  content = yield(self)
347
- __text__(content) if original_length == buffer.bytesize
332
+ __implicit_output__(content) if original_length == buffer.bytesize
348
333
 
349
334
  nil
350
335
  end
351
336
 
352
- # Same as {#yield_content} but yields no arguments.
353
- # @yield Yields the block with no arguments.
354
- def yield_content_with_no_args
337
+ def __yield_content_with_no_args__
355
338
  return unless block_given?
356
339
 
357
- buffer = @_context.buffer
340
+ buffer = @_state.buffer
358
341
 
359
342
  original_length = buffer.bytesize
360
343
  content = yield
361
- __text__(content) if original_length == buffer.bytesize
344
+ __implicit_output__(content) if original_length == buffer.bytesize
362
345
 
363
346
  nil
364
347
  end
365
348
 
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(*)
349
+ def __yield_content_with_args__(*a)
370
350
  return unless block_given?
371
351
 
372
- buffer = @_context.buffer
352
+ buffer = @_state.buffer
373
353
 
374
354
  original_length = buffer.bytesize
375
- content = yield(*)
376
- __text__(content) if original_length == buffer.bytesize
355
+ content = yield(*a)
356
+ __implicit_output__(content) if original_length == buffer.bytesize
377
357
 
378
358
  nil
379
359
  end
380
360
 
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
361
+ def __implicit_output__(content)
362
+ state = @_state
363
+ return true unless state.should_render?
364
+
365
+ case content
366
+ when Phlex::SGML::SafeObject
367
+ state.buffer << content.to_s
368
+ when String
369
+ state.buffer << Phlex::Escape.html_escape(content)
370
+ when Symbol
371
+ state.buffer << Phlex::Escape.html_escape(content.name)
372
+ when nil
373
+ nil
374
+ else
375
+ if (formatted_object = format_object(content))
376
+ state.buffer << Phlex::Escape.html_escape(formatted_object)
377
+ else
378
+ return false
379
+ end
380
+ end
381
+
382
+ true
383
+ end
384
+
385
+ # same as __implicit_output__ but escapes even `safe` objects
383
386
  def __text__(content)
384
- context = @_context
385
- return true if context.fragments && !context.in_target_fragment
387
+ state = @_state
388
+ return true unless state.should_render?
386
389
 
387
390
  case content
388
391
  when String
389
- context.buffer << Phlex::Escape.html_escape(content)
392
+ state.buffer << Phlex::Escape.html_escape(content)
390
393
  when Symbol
391
- context.buffer << Phlex::Escape.html_escape(content.name)
394
+ state.buffer << Phlex::Escape.html_escape(content.name)
392
395
  when nil
393
396
  nil
394
397
  else
395
398
  if (formatted_object = format_object(content))
396
- context.buffer << Phlex::Escape.html_escape(formatted_object)
399
+ state.buffer << Phlex::Escape.html_escape(formatted_object)
397
400
  else
398
401
  return false
399
402
  end
@@ -402,7 +405,6 @@ class Phlex::SGML
402
405
  true
403
406
  end
404
407
 
405
- # @api private
406
408
  def __attributes__(attributes, buffer = +"")
407
409
  attributes.each do |k, v|
408
410
  next unless v
@@ -413,78 +415,87 @@ class Phlex::SGML
413
415
  else raise Phlex::ArgumentError.new("Attribute keys should be Strings or Symbols.")
414
416
  end
415
417
 
416
- lower_name = name.downcase
417
-
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
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
418
+ value = case v
438
419
  when true
439
- buffer << " " << name
420
+ true
440
421
  when String
441
- buffer << " " << name << '="' << v.gsub('"', "&quot;") << '"'
422
+ v.gsub('"', "&quot;")
442
423
  when Symbol
443
- buffer << " " << name << '="' << v.name.tr("_", "-").gsub('"', "&quot;") << '"'
424
+ v.name.tr("_", "-").gsub('"', "&quot;")
444
425
  when Integer, Float
445
- buffer << " " << name << '="' << v.to_s << '"'
426
+ v.to_s
446
427
  when Hash
447
428
  case k
448
- when :class
449
- buffer << " " << name << '="' << __classes__(v).gsub('"', "&quot;") << '"'
450
429
  when :style
451
- buffer << " " << name << '="' << __styles__(v).gsub('"', "&quot;") << '"'
430
+ __styles__(v).gsub('"', "&quot;")
452
431
  else
453
432
  __nested_attributes__(v, "#{name}-", buffer)
454
433
  end
455
434
  when Array
456
- value = case k
457
- when :class
458
- __classes__(v)
435
+ case k
459
436
  when :style
460
- __styles__(v)
437
+ __styles__(v).gsub('"', "&quot;")
461
438
  else
462
439
  __nested_tokens__(v)
463
440
  end
464
-
465
- buffer << " " << name << '="' << value.gsub('"', "&quot;") << '"'
466
441
  when Set
467
- buffer << " " << name << '="' << __nested_tokens__(v.to_a) << '"'
442
+ case k
443
+ when :style
444
+ __styles__(v).gsub('"', "&quot;")
445
+ else
446
+ __nested_tokens__(v.to_a)
447
+ end
468
448
  when Phlex::SGML::SafeObject
469
- buffer << " " << name << '="' << v.to_s.gsub('"', "&quot;") << '"'
449
+ v.to_s.gsub('"', "&quot;")
470
450
  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
451
+ raise Phlex::ArgumentError.new("Invalid attribute value for #{k}: #{v.inspect}.")
452
+ end
453
+
454
+ lower_name = name.downcase
455
+
456
+ unless Phlex::SGML::SafeObject === v
457
+ normalized_name = lower_name.delete("^a-z-")
458
+
459
+ if value != true && REF_ATTRIBUTES.include?(normalized_name)
460
+ case value
461
+ when String
462
+ if value.downcase.delete("^a-z:").start_with?("javascript:")
463
+ # We just ignore these because they were likely not specified by the developer.
464
+ next
465
+ end
466
+ else
467
+ raise Phlex::ArgumentError.new("Invalid attribute value for #{k}: #{v.inspect}.")
468
+ end
469
+ end
470
+
471
+ if normalized_name.bytesize > 2 && normalized_name.start_with?("on") && !normalized_name.include?("-")
472
+ raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
473
+ end
474
+
475
+ if UNSAFE_ATTRIBUTES.include?(normalized_name)
476
+ raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
477
477
  end
478
+ end
479
+
480
+ if name.match?(/[<>&"']/)
481
+ raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
482
+ end
483
+
484
+ if lower_name.to_sym == :id && k != :id
485
+ raise Phlex::ArgumentError.new(":id attribute should only be passed as a lowercase symbol.")
486
+ end
478
487
 
479
- buffer << " " << name << '="' << value.gsub('"', "&quot;") << '"'
488
+ case value
489
+ when true
490
+ buffer << " " << name
491
+ when String
492
+ buffer << " " << name << '="' << value << '"'
480
493
  end
481
494
  end
482
495
 
483
496
  buffer
484
497
  end
485
498
 
486
- # @api private
487
- #
488
499
  # Provides the nested-attributes case for serializing out attributes.
489
500
  # This allows us to skip many of the checks the `__attributes__` method must perform.
490
501
  def __nested_attributes__(attributes, base_name, buffer = +"")
@@ -497,6 +508,10 @@ class Phlex::SGML
497
508
  else raise Phlex::ArgumentError.new("Attribute keys should be Strings or Symbols")
498
509
  end
499
510
 
511
+ if name.match?(/[<>&"']/)
512
+ raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
513
+ end
514
+
500
515
  case v
501
516
  when true
502
517
  buffer << " " << base_name << name
@@ -512,22 +527,16 @@ class Phlex::SGML
512
527
  buffer << " " << base_name << name << '="' << __nested_tokens__(v) << '"'
513
528
  when Set
514
529
  buffer << " " << base_name << name << '="' << __nested_tokens__(v.to_a) << '"'
530
+ when Phlex::SGML::SafeObject
531
+ buffer << " " << base_name << name << '="' << v.to_s.gsub('"', "&quot;") << '"'
515
532
  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;") << '"'
533
+ raise Phlex::ArgumentError.new("Invalid attribute value #{v.inspect}.")
524
534
  end
525
535
 
526
536
  buffer
527
537
  end
528
538
  end
529
539
 
530
- # @api private
531
540
  def __nested_tokens__(tokens)
532
541
  buffer = +""
533
542
 
@@ -549,93 +558,105 @@ class Phlex::SGML
549
558
  else
550
559
  buffer << token.name.tr("_", "-")
551
560
  end
552
- when nil
553
- # Do nothing
554
- else
561
+ when Integer, Float, Phlex::SGML::SafeObject
555
562
  if i > 0
556
563
  buffer << " " << token.to_s
557
564
  else
558
565
  buffer << token.to_s
559
566
  end
567
+ when Array
568
+ if token.length > 0
569
+ if i > 0
570
+ buffer << " " << __nested_tokens__(token)
571
+ else
572
+ buffer << __nested_tokens__(token)
573
+ end
574
+ end
575
+ when nil
576
+ # Do nothing
577
+ else
578
+ raise Phlex::ArgumentError.new("Invalid token type: #{token.class}.")
560
579
  end
561
580
 
562
581
  i += 1
563
582
  end
564
583
 
565
- buffer.gsub!('"', "&quot;")
566
- buffer
584
+ buffer.gsub('"', "&quot;")
567
585
  end
568
586
 
569
- # @api private
570
- def __classes__(c)
571
- case c
572
- when String
573
- c
574
- when Symbol
575
- c.name.tr("_", "-")
587
+ # Result is **unsafe**, so it should be escaped!
588
+ def __styles__(styles)
589
+ case styles
576
590
  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.")
591
+ styles.filter_map do |s|
592
+ case s
593
+ when String
594
+ if s == "" || s.end_with?(";")
595
+ s
596
+ else
597
+ "#{s};"
598
+ end
599
+ when Phlex::SGML::SafeObject
600
+ value = s.to_s
601
+ value.end_with?(";") ? value : "#{value};"
602
+ when Hash
603
+ next __styles__(s)
604
+ when nil
605
+ next nil
606
+ else
607
+ raise Phlex::ArgumentError.new("Invalid style: #{s.inspect}.")
585
608
  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
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
609
+ end.join(" ")
611
610
  when Hash
612
611
  buffer = +""
613
- s.each do |k, v|
612
+ i = 0
613
+ styles.each do |k, v|
614
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.")
615
+ when String
616
+ k
617
+ when Symbol
618
+ k.name.tr("_", "-")
619
+ else
620
+ raise Phlex::ArgumentError.new("Style keys should be Strings or Symbols.")
618
621
  end
619
622
 
620
- value = __styles__(v)
623
+ value = case v
624
+ when String
625
+ v
626
+ when Symbol
627
+ v.name.tr("_", "-")
628
+ when Integer, Float, Phlex::SGML::SafeObject
629
+ v.to_s
630
+ when nil
631
+ nil
632
+ else
633
+ raise Phlex::ArgumentError.new("Invalid style value: #{v.inspect}")
634
+ end
621
635
 
622
636
  if value
623
- buffer << prop << ":" << value
637
+ if i == 0
638
+ buffer << prop << ": " << value << ";"
639
+ else
640
+ buffer << " " << prop << ": " << value << ";"
641
+ end
624
642
  end
643
+
644
+ i += 1
625
645
  end
646
+
626
647
  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
637
648
  end
649
+ end
650
+
651
+ private_class_method def self.method_added(method_name)
652
+ if method_name == :view_template
653
+ location = instance_method(method_name).source_location[0]
638
654
 
639
- style.end_with?(";") ? style : "#{style};"
655
+ if location[0] in "/" | "."
656
+ Phlex.__expand_attribute_cache__(location)
657
+ end
658
+ else
659
+ super
660
+ end
640
661
  end
641
662
  end