lib/phlex/html.rb



# frozen_string_literal: true

if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.0")
	using Overrides::Symbol::Name
end

module Phlex
	class HTML
		DOCTYPE = "<!DOCTYPE html>"

		STANDARD_ELEMENTS = {
			a: "a",
			abbr: "abbr",
			address: "address",
			article: "article",
			aside: "aside",
			b: "b",
			bdi: "bdi",
			bdo: "bdo",
			blockquote: "blockquote",
			body: "body",
			button: "button",
			caption: "caption",
			cite: "cite",
			code: "code",
			colgroup: "colgroup",
			data: "data",
			datalist: "datalist",
			dd: "dd",
			del: "del",
			details: "details",
			dfn: "dfn",
			dialog: "dialog",
			div: "div",
			dl: "dl",
			dt: "dt",
			em: "em",
			fieldset: "fieldset",
			figcaption: "figcaption",
			figure: "figure",
			footer: "footer",
			form: "form",
			g: "g",
			h1: "h1",
			h2: "h2",
			h3: "h3",
			h4: "h4",
			h5: "h5",
			h6: "h6",
			head: "head",
			header: "header",
			html: "html",
			i: "i",
			iframe: "iframe",
			ins: "ins",
			kbd: "kbd",
			label: "label",
			legend: "legend",
			li: "li",
			main: "main",
			map: "map",
			mark: "mark",
			menuitem: "menuitem",
			meter: "meter",
			nav: "nav",
			noscript: "noscript",
			object: "object",
			ol: "ol",
			optgroup: "optgroup",
			option: "option",
			output: "output",
			p: "p",
			path: "path",
			picture: "picture",
			pre: "pre",
			progress: "progress",
			q: "q",
			rp: "rp",
			rt: "rt",
			ruby: "ruby",
			s: "s",
			samp: "samp",
			script: "script",
			section: "section",
			select: "select",
			slot: "slot",
			small: "small",
			span: "span",
			strong: "strong",
			style: "style",
			sub: "sub",
			summary: "summary",
			sup: "sup",
			svg: "svg",
			table: "table",
			tbody: "tbody",
			td: "td",
			template_tag: "template",
			textarea: "textarea",
			tfoot: "tfoot",
			th: "th",
			thead: "thead",
			time: "time",
			title: "title",
			tr: "tr",
			u: "u",
			ul: "ul",
			video: "video",
			wbr: "wbr",
		}.freeze

		VOID_ELEMENTS = {
			area: "area",
			br: "br",
			embed: "embed",
			hr: "hr",
			img: "img",
			input: "input",
			link: "link",
			meta: "meta",
			param: "param",
			source: "source",
			track: "track",
			col: "col",
		}.freeze

		EVENT_ATTRIBUTES = %w[onabort onafterprint onbeforeprint onbeforeunload onblur oncanplay oncanplaythrough onchange onclick oncontextmenu oncopy oncuechange oncut ondblclick ondrag ondragend ondragenter ondragleave ondragover ondragstart ondrop ondurationchange onemptied onended onerror onerror onfocus onhashchange oninput oninvalid onkeydown onkeypress onkeyup onload onloadeddata onloadedmetadata onloadstart onmessage onmousedown onmousemove onmouseout onmouseover onmouseup onmousewheel onoffline ononline onpagehide onpageshow onpaste onpause onplay onplaying onpopstate onprogress onratechange onreset onresize onscroll onsearch onseeked onseeking onselect onstalled onstorage onsubmit onsuspend ontimeupdate ontoggle onunload onvolumechange onwaiting onwheel].to_h { [_1, true] }.freeze

		extend Elements
		include Helpers

		class << self
			attr_accessor :rendered_at_least_once

			def call(...)
				new(...).call
			end
			alias_method :render, :call

			def new(*args, **kwargs, &block)
				if block
					object = super(*args, **kwargs, &nil)
					object.instance_variable_set(:@_content_block, block)
					object
				else
					super
				end
			end
		end

		def call(buffer = +"", view_context: nil, parent: nil, &block)
			@_target = buffer
			@_view_context = view_context
			@_parent = parent

			block ||= @_content_block

			return buffer unless render?

			around_template do
				if block
					if DeferredRender === self
						__vanish__(self, &block)
						template
					else
						template do |*args|
							if args.length > 0
								yield_content_with_args(*args, &block)
							else
								yield_content(&block)
							end
						end
					end
				else
					template
				end
			end

			self.class.rendered_at_least_once = true

			buffer
		end

		def render(renderable, &block)
			case renderable
			when Phlex::HTML
				renderable.call(@_target, view_context: @_view_context, parent: self, &block)
			when Class
				if renderable < Phlex::HTML
					renderable.new.call(@_target, view_context: @_view_context, parent: self, &block)
				end
			else
				raise ArgumentError, "You can't render a #{renderable}."
			end

			nil
		end

		def format
			:html
		end

		STANDARD_ELEMENTS.each do |method_name, tag|
			register_element(method_name, tag: tag)
		end

		VOID_ELEMENTS.each do |method_name, tag|
			register_void_element(method_name, tag: tag)
		end

		def text(content)
			@_target << ERB::Util.html_escape(
				case content
					when String then content
					when Symbol then content.name
					when Integer then content.to_s
					else format_object(content) || content.to_s
				end
			)

			nil
		end

		def whitespace
			@_target << " "

			if block_given?
				yield
				@_target << " "
			end

			nil
		end

		def comment(&block)
			@_target << "<!-- "
			yield_content(&block)
			@_target << " -->"

			nil
		end

		def doctype
			@_target << DOCTYPE
			nil
		end

		def unsafe_raw(content = nil)
			return nil unless content

			@_target << content
		end

		def capture
			return unless block_given?

			original_buffer = @_target
			new_buffer = +""
			@_target = new_buffer

			yield

			new_buffer
		ensure
			@_target = original_buffer
		end

		# Like `capture` but the output is vanished into a BlackHole buffer.
		# Becuase the BlackHole does nothing with the output, this should be faster.
		def __vanish__(*args)
			return unless block_given?

			original_buffer = @_target
			@_target = BlackHole

			yield(*args)
			nil
		ensure
			@_target = original_buffer
		end

		# Default render predicate can be overridden to prevent rendering
		private def render?
			true
		end

		private def format_object(object)
			case object
			when Float
				object.to_s
			end
		end

		private def around_template
			before_template
			yield
			after_template
		end

		private def before_template
			nil
		end

		private def after_template
			nil
		end

		private def yield_content
			return unless block_given?

			original_length = @_target.length
			content = yield(self)
			unchanged = (original_length == @_target.length)

			if unchanged
				case content
				when String
					@_target << ERB::Util.html_escape(content)
				when Symbol
					@_target << ERB::Util.html_escape(content.name)
				when Integer
					@_target << ERB::Util.html_escape(content.to_s)
				else
					if (formatted_object = format_object(content))
						@_target << ERB::Util.html_escape(formatted_object)
					end
				end
			end

			nil
		end

		private def yield_content_with_args(*args)
			return unless block_given?

			original_length = @_target.length
			content = yield(*args)
			unchanged = (original_length == @_target.length)

			if unchanged
				case content
				when String
					@_target << ERB::Util.html_escape(content)
				when Symbol
					@_target << ERB::Util.html_escape(content.name)
				when Integer, Float
					@_target << ERB::Util.html_escape(content.to_s)
				else
					if (formatted_object = format_object(content))
						@_target << ERB::Util.html_escape(formatted_object)
					end
				end
			end

			nil
		end

		private def __attributes__(**attributes)
			if attributes[:href]&.start_with?(/\s*javascript/)
				attributes[:href] = attributes[:href].sub(/^\s*(javascript:)+/, "")
			end

			buffer = +""
			__build_attributes__(attributes, buffer: buffer)

			unless self.class.rendered_at_least_once
				Phlex::ATTRIBUTE_CACHE[attributes.hash] = buffer.freeze
			end

			buffer
		end

		private def __build_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 k.to_s
				end

				# Detect unsafe attribute names. Attribute names are considered unsafe if they match an event attribute or include unsafe characters.
				if HTML::EVENT_ATTRIBUTES[name] || name.match?(/[<>&"']/)
					raise ArgumentError, "Unsafe attribute name detected: #{k}."
				end

				case v
				when true
					buffer << " " << name
				when String
					buffer << " " << name << '="' << ERB::Util.html_escape(v) << '"'
				when Symbol
					buffer << " " << name << '="' << ERB::Util.html_escape(v.name) << '"'
				when Hash
					__build_attributes__(
						v.transform_keys { |subkey|
							case subkey
								when Symbol then"#{k}-#{subkey.name.tr('_', '-')}"
								else "#{k}-#{subkey}"
							end
						}, buffer: buffer
					)
				else
					buffer << " " << name << '="' << ERB::Util.html_escape(v.to_s) << '"'
				end
			end

			buffer
		end

		# This should be the last method defined
		def self.method_added(method_name)
			if method_name[0] == "_" && Phlex::HTML.instance_methods.include?(method_name) && instance_method(method_name).owner != Phlex::HTML
				raise NameError, "👋 Redefining the method `#{name}##{method_name}` is not a good idea."
			end

			super
		end
	end
end