# frozen_string_literal: true
# **Standard Generalized Markup Language** for behaviour common to {HTML} and {SVG}.
class Phlex::SGML
UNSAFE_ATTRIBUTES = Set.new(%w[srcdoc sandbox http-equiv]).freeze
REF_ATTRIBUTES = Set.new(%w[href src action formaction lowsrc dynsrc background ping]).freeze
autoload :Elements, "phlex/sgml/elements"
autoload :SafeObject, "phlex/sgml/safe_object"
autoload :SafeValue, "phlex/sgml/safe_value"
include Phlex::Helpers
class << self
# Render the view to a String. Arguments are delegated to {.new}.
def call(...)
new(...).call
end
# Create a new instance of the component.
# @note The block will not be delegated {#initialize}. Instead, it will be sent to {#template} when rendering.
def new(*a, **k, &block)
if block
object = super(*a, **k, &nil)
object.instance_exec { @_content_block = block }
object
else
super
end
end
def __element_method__?(method_name)
if instance_methods.include?(method_name)
owner = instance_method(method_name).owner
if Phlex::SGML::Elements === owner && owner.__registered_elements__[method_name]
true
else
false
end
else
false
end
end
end
def view_template
if block_given?
yield
end
end
def to_proc
proc { |c| c.render(self) }
end
def call(buffer = +"", context: {}, view_context: nil, parent: nil, fragments: nil, &block)
@_buffer = buffer
@_context = phlex_context = parent&.__context__ || Phlex::Context.new(user_context: context, view_context:)
@_parent = parent
raise Phlex::DoubleRenderError.new("You can't render a #{self.class.name} more than once.") if @_rendered
@_rendered = true
if fragments
phlex_context.target_fragments(fragments)
end
block ||= @_content_block
return "" unless render?
Thread.current[:__phlex_component__] = [self, Fiber.current.object_id]
phlex_context.around_render do
before_template(&block)
around_template do
if block
view_template do |*args|
if args.length > 0
__yield_content_with_args__(*args, &block)
else
__yield_content__(&block)
end
end
else
view_template
end
end
after_template(&block)
end
unless parent
buffer << phlex_context.buffer
end
ensure
Thread.current[:__phlex_component__] = [parent, Fiber.current.object_id]
end
protected def __context__ = @_context
def context
@_context.user_context
end
# Output plain text.
def plain(content)
unless __text__(content)
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")
end
nil
end
# Output a single space character. If a block is given, a space will be output before and after the block.
def whitespace(&)
context = @_context
return if context.fragments && !context.in_target_fragment
buffer = context.buffer
buffer << " "
if block_given?
__yield_content__(&)
buffer << " "
end
nil
end
# Wrap the output in an HTML comment.
#
# [MDN Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Comments)
def comment(&)
context = @_context
return if context.fragments && !context.in_target_fragment
buffer = context.buffer
buffer << "<!-- "
__yield_content__(&)
buffer << " -->"
nil
end
# Output the given safe object as-is. You may need to use `safe` to mark a string as a safe object.
def raw(content)
case content
when Phlex::SGML::SafeObject
context = @_context
return if context.fragments && !context.in_target_fragment
context.buffer << content.to_s
when nil, "" # do nothing
else
raise Phlex::ArgumentError.new("You passed an unsafe object to `raw`.")
end
nil
end
# Capture the output of the block and returns it as a string.
def capture(*args, &block)
return "" unless block
if args.length > 0
@_context.capturing_into(+"") { __yield_content_with_args__(*args, &block) }
else
@_context.capturing_into(+"") { __yield_content__(&block) }
end
end
# Mark the given string as safe for HTML output.
def safe(value)
case value
when String
Phlex::SGML::SafeValue.new(value)
else
raise Phlex::ArgumentError.new("Expected a String.")
end
end
alias_method :🦺, :safe
def flush
return if @_context.capturing
buffer = @_context.buffer
@_buffer << buffer.dup
buffer.clear
end
def render(renderable = nil, &)
case renderable
when Phlex::SGML
renderable.call(@_buffer, parent: self, &)
when Class
if renderable < Phlex::SGML
renderable.new.call(@_buffer, parent: self, &)
end
when Enumerable
renderable.each { |r| render(r, &) }
when Proc, Method
if renderable.arity == 0
__yield_content_with_no_args__(&renderable)
else
__yield_content__(&renderable)
end
when String
plain(renderable)
when nil
__yield_content__(&) if block_given?
else
raise Phlex::ArgumentError.new("You can't render a #{renderable.inspect}.")
end
nil
end
private
def vanish(*args)
return unless block_given?
if args.length > 0
@_context.capturing_into(Phlex::Vanish) { yield(*args) }
else
@_context.capturing_into(Phlex::Vanish) { yield(self) }
end
nil
end
def render?
true
end
def format_object(object)
case object
when Float, Integer
object.to_s
end
end
def around_template
yield
nil
end
def before_template
nil
end
def after_template
nil
end
def __yield_content__
return unless block_given?
buffer = @_context.buffer
original_length = buffer.bytesize
content = yield(self)
__implicit_output__(content) if original_length == buffer.bytesize
nil
end
def __yield_content_with_no_args__
return unless block_given?
buffer = @_context.buffer
original_length = buffer.bytesize
content = yield
__implicit_output__(content) if original_length == buffer.bytesize
nil
end
def __yield_content_with_args__(*a)
return unless block_given?
buffer = @_context.buffer
original_length = buffer.bytesize
content = yield(*a)
__implicit_output__(content) if original_length == buffer.bytesize
nil
end
def __implicit_output__(content)
context = @_context
return true if context.fragments && !context.in_target_fragment
case content
when Phlex::SGML::SafeObject
context.buffer << content.to_s
when String
context.buffer << Phlex::Escape.html_escape(content)
when Symbol
context.buffer << Phlex::Escape.html_escape(content.name)
when nil
nil
else
if (formatted_object = format_object(content))
context.buffer << Phlex::Escape.html_escape(formatted_object)
else
return false
end
end
true
end
# same as __implicit_output__ but escapes even `safe` objects
def __text__(content)
context = @_context
return true if context.fragments && !context.in_target_fragment
case content
when String
context.buffer << Phlex::Escape.html_escape(content)
when Symbol
context.buffer << Phlex::Escape.html_escape(content.name)
when nil
nil
else
if (formatted_object = format_object(content))
context.buffer << Phlex::Escape.html_escape(formatted_object)
else
return false
end
end
true
end
def __attributes__(attributes, buffer = +"")
attributes.each do |k, v|
next unless v
name = case k
when String then k
when Symbol then k.name.tr("_", "-")
else raise Phlex::ArgumentError.new("Attribute keys should be Strings or Symbols.")
end
value = case v
when true
true
when String
v.gsub('"', """)
when Symbol
v.name.tr("_", "-").gsub('"', """)
when Integer, Float
v.to_s
when Hash
case k
when :style
__styles__(v).gsub('"', """)
else
__nested_attributes__(v, "#{name}-", buffer)
end
when Array
case k
when :style
__styles__(v).gsub('"', """)
else
__nested_tokens__(v)
end
when Set
case k
when :style
__styles__(v).gsub('"', """)
else
__nested_tokens__(v.to_a)
end
when Phlex::SGML::SafeObject
v.to_s.gsub('"', """)
else
raise Phlex::ArgumentError.new("Invalid attribute value for #{k}: #{v.inspect}.")
end
lower_name = name.downcase
unless Phlex::SGML::SafeObject === v
normalized_name = lower_name.delete("^a-z-")
if value != true && REF_ATTRIBUTES.include?(normalized_name)
case value
when String
if value.downcase.delete("^a-z:").start_with?("javascript:")
# We just ignore these because they were likely not specified by the developer.
next
end
else
raise Phlex::ArgumentError.new("Invalid attribute value for #{k}: #{v.inspect}.")
end
end
if normalized_name.bytesize > 2 && normalized_name.start_with?("on") && !normalized_name.include?("-")
raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
end
if UNSAFE_ATTRIBUTES.include?(normalized_name)
raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
end
end
if name.match?(/[<>&"']/)
raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
end
if lower_name.to_sym == :id && k != :id
raise Phlex::ArgumentError.new(":id attribute should only be passed as a lowercase symbol.")
end
case value
when true
buffer << " " << name
when String
buffer << " " << name << '="' << value << '"'
end
end
buffer
end
# Provides the nested-attributes case for serializing out attributes.
# This allows us to skip many of the checks the `__attributes__` method must perform.
def __nested_attributes__(attributes, base_name, buffer = +"")
attributes.each do |k, v|
next unless v
name = case k
when String then k
when Symbol then k.name.tr("_", "-")
else raise Phlex::ArgumentError.new("Attribute keys should be Strings or Symbols")
end
if name.match?(/[<>&"']/)
raise Phlex::ArgumentError.new("Unsafe attribute name detected: #{k}.")
end
case v
when true
buffer << " " << base_name << name
when String
buffer << " " << base_name << name << '="' << v.gsub('"', """) << '"'
when Symbol
buffer << " " << base_name << name << '="' << v.name.tr("_", "-").gsub('"', """) << '"'
when Integer, Float
buffer << " " << base_name << name << '="' << v.to_s << '"'
when Hash
__nested_attributes__(v, "#{base_name}#{name}-", buffer)
when Array
buffer << " " << base_name << name << '="' << __nested_tokens__(v) << '"'
when Set
buffer << " " << base_name << name << '="' << __nested_tokens__(v.to_a) << '"'
when Phlex::SGML::SafeObject
buffer << " " << base_name << name << '="' << v.to_s.gsub('"', """) << '"'
else
raise Phlex::ArgumentError.new("Invalid attribute value #{v.inspect}.")
end
buffer
end
end
def __nested_tokens__(tokens)
buffer = +""
i, length = 0, tokens.length
while i < length
token = tokens[i]
case token
when String
if i > 0
buffer << " " << token
else
buffer << token
end
when Symbol
if i > 0
buffer << " " << token.name.tr("_", "-")
else
buffer << token.name.tr("_", "-")
end
when Integer, Float, Phlex::SGML::SafeObject
if i > 0
buffer << " " << token.to_s
else
buffer << token.to_s
end
when Array
if token.length > 0
if i > 0
buffer << " " << __nested_tokens__(token)
else
buffer << __nested_tokens__(token)
end
end
when nil
# Do nothing
else
raise Phlex::ArgumentError.new("Invalid token type: #{token.class}.")
end
i += 1
end
buffer.gsub('"', """)
end
# Result is **unsafe**, so it should be escaped!
def __styles__(styles)
case styles
when Array, Set
styles.filter_map do |s|
case s
when String
if s == "" || s.end_with?(";")
s
else
"#{s};"
end
when Phlex::SGML::SafeObject
value = s.to_s
value.end_with?(";") ? value : "#{value};"
when Hash
next __styles__(s)
when nil
next nil
else
raise Phlex::ArgumentError.new("Invalid style: #{s.inspect}.")
end
end.join(" ")
when Hash
buffer = +""
i = 0
styles.each do |k, v|
prop = case k
when String
k
when Symbol
k.name.tr("_", "-")
else
raise Phlex::ArgumentError.new("Style keys should be Strings or Symbols.")
end
value = case v
when String
v
when Symbol
v.name.tr("_", "-")
when Integer, Float, Phlex::SGML::SafeObject
v.to_s
when nil
nil
else
raise Phlex::ArgumentError.new("Invalid style value: #{v.inspect}")
end
if value
if i == 0
buffer << prop << ": " << value << ";"
else
buffer << " " << prop << ": " << value << ";"
end
end
i += 1
end
buffer
end
end
private_class_method def self.method_added(method_name)
if method_name == :view_template
location = instance_method(method_name).source_location[0]
if location[0] in "/" | "."
Phlex.__expand_attribute_cache__(location)
end
else
super
end
end
end